mirror of
https://github.com/jazzband/django-axes.git
synced 2026-05-16 03:23:11 +00:00
Compare commits
No commits in common. "master" and "6.0.1" have entirely different histories.
53 changed files with 285 additions and 1674 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:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- 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
|
# Override language selection by uncommenting this and choosing your languages
|
||||||
# with:
|
# with:
|
||||||
# languages: go, javascript, csharp, python, cpp, java
|
# languages: go, javascript, csharp, python, cpp, java
|
||||||
|
|
@ -26,7 +26,7 @@ jobs:
|
||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below).
|
# If this step fails, then you should remove it and run the build manually (see below).
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v4
|
uses: github/codeql-action/autobuild@v2
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
|
|
@ -40,4 +40,4 @@ jobs:
|
||||||
# make release
|
# make release
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v4
|
uses: github/codeql-action/analyze@v2
|
||||||
|
|
|
||||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
|
@ -14,14 +14,14 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: 3.12
|
python-version: 3.8
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -40,4 +40,4 @@ jobs:
|
||||||
with:
|
with:
|
||||||
user: jazzband
|
user: jazzband
|
||||||
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
||||||
repository-url: https://jazzband.co/projects/django-axes/upload
|
repository_url: https://jazzband.co/projects/django-axes/upload
|
||||||
|
|
|
||||||
35
.github/workflows/test.yml
vendored
35
.github/workflows/test.yml
vendored
|
|
@ -11,32 +11,39 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
max-parallel: 5
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
python-version: ['3.8', '3.9', '3.10', '3.11']
|
||||||
django-version: ['4.2', '5.2', '6.0']
|
django-version: ['3.2', '4.1', '4.2']
|
||||||
include:
|
include:
|
||||||
# Tox configuration for QA environment
|
# Tox configuration for QA environment
|
||||||
- python-version: '3.14'
|
- python-version: '3.11'
|
||||||
django-version: 'qa'
|
django-version: 'qa'
|
||||||
# Django main
|
# Django main
|
||||||
- python-version: '3.14'
|
- python-version: '3.11'
|
||||||
django-version: 'main'
|
django-version: 'main'
|
||||||
experimental: true
|
experimental: true
|
||||||
exclude:
|
# PyPy 3.8
|
||||||
- python-version: '3.13'
|
- 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'
|
django-version: '4.2'
|
||||||
- python-version: '3.9'
|
experimental: true
|
||||||
django-version: '5.2'
|
exclude:
|
||||||
- python-version: '3.10'
|
# Exclude Python 3.11 for Django 3.2 and Django 4.0
|
||||||
django-version: '6.0'
|
|
||||||
- python-version: '3.11'
|
- python-version: '3.11'
|
||||||
django-version: '6.0'
|
django-version: '3.2'
|
||||||
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
|
@ -46,7 +53,7 @@ jobs:
|
||||||
echo "::set-output name=dir::$(pip cache dir)"
|
echo "::set-output name=dir::$(pip cache dir)"
|
||||||
|
|
||||||
- name: Cache
|
- name: Cache
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pip-cache.outputs.dir }}
|
path: ${{ steps.pip-cache.outputs.dir }}
|
||||||
key:
|
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
|
|
||||||
189
CHANGES.rst
189
CHANGES.rst
|
|
@ -2,195 +2,6 @@
|
||||||
Changes
|
Changes
|
||||||
=======
|
=======
|
||||||
|
|
||||||
8.3.1 (2026-02-11)
|
|
||||||
------------------
|
|
||||||
|
|
||||||
- Fix configuration JSON serialization errors for Celery.
|
|
||||||
[aleksihakli]
|
|
||||||
|
|
||||||
8.3.0 (2026-02-09)
|
|
||||||
------------------
|
|
||||||
|
|
||||||
- Remove deprecated pkg_resources in favour of new importlib.
|
|
||||||
[hugovk]
|
|
||||||
|
|
||||||
8.2.0 (2026-02-06)
|
|
||||||
------------------
|
|
||||||
|
|
||||||
- Fix AttributeError when optional settings are undefined.
|
|
||||||
[rodrigo.nogueira]
|
|
||||||
- Fix circular import with custom user models.
|
|
||||||
[rodrigo.nogueira]
|
|
||||||
- Add unit tests for security check W006.
|
|
||||||
[shayanTaki]
|
|
||||||
|
|
||||||
8.1.0 (2025-12-19)
|
|
||||||
------------------
|
|
||||||
|
|
||||||
- Add Persion (fa) translations for django-axes.
|
|
||||||
[AmirAli-BahramJerdi]
|
|
||||||
- Add individual attempt expiry support.
|
|
||||||
[kuldeepkhatke]
|
|
||||||
- Add checks for missing ip_address in lockout params.
|
|
||||||
[shayanTaki]
|
|
||||||
- Add missing ``settings.AXES_IPWARE_PROXY_ORDER`` documentation.
|
|
||||||
[ram98kgp]
|
|
||||||
- Enhance ``get_lockout_response`` to receive original response as parameter.
|
|
||||||
[mounirmesselmeni]
|
|
||||||
- Update documentation.
|
|
||||||
- Add Python 3.14 support.
|
|
||||||
- Add Django 6.0 support.
|
|
||||||
- Remove Python 3.9 support (EOL).
|
|
||||||
- Remove Django 5.1 support (EOL).
|
|
||||||
|
|
||||||
8.0.0 (2025-05-10)
|
|
||||||
------------------
|
|
||||||
|
|
||||||
- Move all database related logic to the default ``axes.handlers.database.AxesDatabaseHandler``.
|
|
||||||
[nefrob]
|
|
||||||
|
|
||||||
7.1.0 (2025-04-23)
|
|
||||||
------------------
|
|
||||||
|
|
||||||
- Provide credentials to expired credentials cleanup method.
|
|
||||||
[parul-aro]
|
|
||||||
- Update support matrix for Django 5.2.
|
|
||||||
[mkniewallner]
|
|
||||||
- Fix documentation.
|
|
||||||
[chango-goat]
|
|
||||||
|
|
||||||
|
|
||||||
7.0.2 (2025-02-19)
|
|
||||||
------------------
|
|
||||||
|
|
||||||
- Fix documentation.
|
|
||||||
[Jacobus-afk]
|
|
||||||
- Default to using ``settings.AUTH_USER_MODEL.USERNAME_FIELD`` for resolving ``settings.AXES_USERNAME_FORM_FIELD`` if otherwise unset (previously "username").
|
|
||||||
[amneher]
|
|
||||||
|
|
||||||
|
|
||||||
7.0.1 (2024-12-02)
|
|
||||||
------------------
|
|
||||||
|
|
||||||
- Add Python 3.13 support.
|
|
||||||
[aleksihakli]
|
|
||||||
- Deprecate Python 3.8 support.
|
|
||||||
[aleksihakli]
|
|
||||||
|
|
||||||
|
|
||||||
7.0.0 (2024-10-02)
|
|
||||||
------------------
|
|
||||||
|
|
||||||
- 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)
|
6.0.1 (2023-05-17)
|
||||||
------------------
|
------------------
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,8 @@ or alternatively use a fast and DDoS resistant cache implementation.
|
||||||
Axes can be configured to monitor login attempts by
|
Axes can be configured to monitor login attempts by
|
||||||
IP address, username, user agent, or their combinations.
|
IP address, username, user agent, or their combinations.
|
||||||
|
|
||||||
Axes supports cool off periods, IP address allow listing and block listing,
|
Axes supports cool off periods, IP address whitelisting and blacklisting,
|
||||||
user account allow listing, and other features for Django access management.
|
user account whitelisting, and other features for Django access management.
|
||||||
|
|
||||||
|
|
||||||
Documentation
|
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.conf import settings
|
||||||
from axes.models import AccessAttempt, AccessLog, AccessFailureLog
|
from axes.models import AccessAttempt, AccessLog, AccessFailureLog
|
||||||
from axes.handlers.database import AxesDatabaseHandler
|
|
||||||
|
|
||||||
|
|
||||||
class IsLockedOutFilter(admin.SimpleListFilter):
|
|
||||||
title = _("Locked Out")
|
|
||||||
parameter_name = "locked_out"
|
|
||||||
|
|
||||||
def lookups(self, request, model_admin):
|
|
||||||
return (
|
|
||||||
("yes", _("Yes")),
|
|
||||||
("no", _("No")),
|
|
||||||
)
|
|
||||||
|
|
||||||
def queryset(self, request, queryset):
|
|
||||||
if self.value() == "yes":
|
|
||||||
return queryset.filter(
|
|
||||||
failures_since_start__gte=settings.AXES_FAILURE_LIMIT
|
|
||||||
)
|
|
||||||
if self.value() == "no":
|
|
||||||
return queryset.filter(failures_since_start__lt=settings.AXES_FAILURE_LIMIT)
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
|
|
||||||
class AccessAttemptAdmin(admin.ModelAdmin):
|
class AccessAttemptAdmin(admin.ModelAdmin):
|
||||||
list_display = [
|
list_display = (
|
||||||
"attempt_time",
|
"attempt_time",
|
||||||
"ip_address",
|
"ip_address",
|
||||||
"user_agent",
|
"user_agent",
|
||||||
"username",
|
"username",
|
||||||
"path_info",
|
"path_info",
|
||||||
"failures_since_start",
|
"failures_since_start",
|
||||||
]
|
)
|
||||||
|
|
||||||
if settings.AXES_USE_ATTEMPT_EXPIRATION:
|
|
||||||
list_display.append("expiration")
|
|
||||||
|
|
||||||
list_filter = ["attempt_time", "path_info"]
|
list_filter = ["attempt_time", "path_info"]
|
||||||
|
|
||||||
if isinstance(settings.AXES_FAILURE_LIMIT, int) and settings.AXES_FAILURE_LIMIT > 0:
|
|
||||||
# This will only add the status field if AXES_FAILURE_LIMIT is set to a positive integer
|
|
||||||
# Because callable failure limit requires scope of request object
|
|
||||||
list_display.append("status")
|
|
||||||
list_filter.append(IsLockedOutFilter) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
search_fields = ["ip_address", "username", "user_agent", "path_info"]
|
search_fields = ["ip_address", "username", "user_agent", "path_info"]
|
||||||
|
|
||||||
date_hierarchy = "attempt_time"
|
date_hierarchy = "attempt_time"
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(None, {"fields": ("path_info", "failures_since_start")}),
|
||||||
None,
|
|
||||||
{"fields": ("username", "path_info", "failures_since_start", "expiration")},
|
|
||||||
),
|
|
||||||
(_("Form Data"), {"fields": ("get_data", "post_data")}),
|
(_("Form Data"), {"fields": ("get_data", "post_data")}),
|
||||||
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
|
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
|
||||||
)
|
)
|
||||||
|
|
@ -71,34 +38,11 @@ class AccessAttemptAdmin(admin.ModelAdmin):
|
||||||
"get_data",
|
"get_data",
|
||||||
"post_data",
|
"post_data",
|
||||||
"failures_since_start",
|
"failures_since_start",
|
||||||
"expiration",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
actions = ["cleanup_expired_attempts"]
|
|
||||||
|
|
||||||
@admin.action(description=_("Clean up expired attempts"))
|
|
||||||
def cleanup_expired_attempts(self, request, queryset): # noqa
|
|
||||||
count = self.handler.clean_expired_user_attempts(request=request)
|
|
||||||
self.message_user(request, _(f"Cleaned up {count} expired access attempts."))
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.handler = AxesDatabaseHandler()
|
|
||||||
|
|
||||||
def has_add_permission(self, request: HttpRequest) -> bool:
|
def has_add_permission(self, request: HttpRequest) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def expiration(self, obj: AccessAttempt):
|
|
||||||
return obj.expiration.expires_at if hasattr(obj, "expiration") else _("Not set")
|
|
||||||
|
|
||||||
def status(self, obj: AccessAttempt):
|
|
||||||
return (
|
|
||||||
f"{settings.AXES_FAILURE_LIMIT - obj.failures_since_start} "
|
|
||||||
+ _("Attempt Remaining")
|
|
||||||
if obj.failures_since_start < settings.AXES_FAILURE_LIMIT
|
|
||||||
else _("Locked Out")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AccessLogAdmin(admin.ModelAdmin):
|
class AccessLogAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
|
|
@ -117,7 +61,7 @@ class AccessLogAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "attempt_time"
|
date_hierarchy = "attempt_time"
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("username", "path_info")}),
|
(None, {"fields": ("path_info",)}),
|
||||||
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
|
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -152,7 +96,7 @@ class AccessFailureLogAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "attempt_time"
|
date_hierarchy = "attempt_time"
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("username", "path_info")}),
|
(None, {"fields": ("path_info",)}),
|
||||||
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
|
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,11 +41,9 @@ class AppConfig(apps.AppConfig):
|
||||||
else:
|
else:
|
||||||
mode = "blocking by " + " or ".join(
|
mode = "blocking by " + " or ".join(
|
||||||
[
|
[
|
||||||
(
|
param
|
||||||
param
|
if isinstance(param, str)
|
||||||
if isinstance(param, str)
|
else "combination of " + " and ".join(param)
|
||||||
else "combination of " + " and ".join(param)
|
|
||||||
)
|
|
||||||
for param in settings.AXES_LOCKOUT_PARAMETERS
|
for param in settings.AXES_LOCKOUT_PARAMETERS
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,106 @@
|
||||||
from logging import getLogger
|
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.http import HttpRequest
|
||||||
from django.utils.timezone import datetime, now
|
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__)
|
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.
|
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:
|
if cool_off is None:
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
"Cool off threshold can not be calculated with settings.AXES_COOLOFF_TIME set to None"
|
"Cool off threshold can not be calculated with settings.AXES_COOLOFF_TIME set to None"
|
||||||
)
|
)
|
||||||
|
|
||||||
attempt_time = request.axes_attempt_time # type: ignore[union-attr]
|
|
||||||
if attempt_time is None:
|
if attempt_time is None:
|
||||||
return now() - cool_off
|
return now() - cool_off
|
||||||
return attempt_time - cool_off
|
return attempt_time - cool_off
|
||||||
|
|
||||||
|
|
||||||
|
def filter_user_attempts(
|
||||||
|
request: HttpRequest, credentials: Optional[dict] = None
|
||||||
|
) -> List[QuerySet]:
|
||||||
|
"""
|
||||||
|
Return a list querysets of AccessAttempts that match the given request and credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
username = get_client_username(request, credentials)
|
||||||
|
|
||||||
|
filter_kwargs_list = get_client_parameters(
|
||||||
|
username, request.axes_ip_address, request.axes_user_agent, request, credentials
|
||||||
|
)
|
||||||
|
attempts_list = [
|
||||||
|
AccessAttempt.objects.filter(**filter_kwargs)
|
||||||
|
for filter_kwargs in filter_kwargs_list
|
||||||
|
]
|
||||||
|
return attempts_list
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_attempts(
|
||||||
|
request: HttpRequest, credentials: Optional[dict] = None
|
||||||
|
) -> List[QuerySet]:
|
||||||
|
"""
|
||||||
|
Get list of querysets with valid user attempts that match the given request and credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
attempts_list = filter_user_attempts(request, credentials)
|
||||||
|
|
||||||
|
if settings.AXES_COOLOFF_TIME is None:
|
||||||
|
log.debug(
|
||||||
|
"AXES: Getting all access attempts from database because no AXES_COOLOFF_TIME is configured"
|
||||||
|
)
|
||||||
|
return attempts_list
|
||||||
|
|
||||||
|
threshold = get_cool_off_threshold(request.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 typing import Optional
|
||||||
from django.conf import settings
|
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 django.http import HttpRequest
|
||||||
|
|
||||||
from axes.exceptions import (
|
from axes.exceptions import (
|
||||||
|
|
@ -11,7 +11,7 @@ from axes.handlers.proxy import AxesProxyHandler
|
||||||
from axes.helpers import get_credentials, get_lockout_message, toggleable
|
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.
|
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."
|
BACKEND_INVALID = "You do not have 'axes.backends.AxesStandaloneBackend' or a subclass in your settings.AUTHENTICATION_BACKENDS."
|
||||||
SETTING_DEPRECATED = "You have a deprecated setting {deprecated_setting} configured in your project settings"
|
SETTING_DEPRECATED = "You have a deprecated setting {deprecated_setting} configured in your project settings"
|
||||||
CALLABLE_INVALID = "{callable_setting} is not a valid callable."
|
|
||||||
LOCKOUT_PARAMETERS_INVALID = (
|
|
||||||
"AXES_LOCKOUT_PARAMETERS does not contain 'ip_address'."
|
|
||||||
" This configuration allows attackers to bypass rate limits by rotating User-Agents or Cookies."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Hints:
|
class Hints:
|
||||||
|
|
@ -33,8 +28,6 @@ class Hints:
|
||||||
MIDDLEWARE_INVALID = None
|
MIDDLEWARE_INVALID = None
|
||||||
BACKEND_INVALID = "AxesModelBackend was renamed to AxesStandaloneBackend in django-axes version 5.0."
|
BACKEND_INVALID = "AxesModelBackend was renamed to AxesStandaloneBackend in django-axes version 5.0."
|
||||||
SETTING_DEPRECATED = None
|
SETTING_DEPRECATED = None
|
||||||
CALLABLE_INVALID = None
|
|
||||||
LOCKOUT_PARAMETERS_INVALID = "Add 'ip_address' to AXES_LOCKOUT_PARAMETERS."
|
|
||||||
|
|
||||||
|
|
||||||
class Codes:
|
class Codes:
|
||||||
|
|
@ -42,8 +35,6 @@ class Codes:
|
||||||
MIDDLEWARE_INVALID = "axes.W002"
|
MIDDLEWARE_INVALID = "axes.W002"
|
||||||
BACKEND_INVALID = "axes.W003"
|
BACKEND_INVALID = "axes.W003"
|
||||||
SETTING_DEPRECATED = "axes.W004"
|
SETTING_DEPRECATED = "axes.W004"
|
||||||
CALLABLE_INVALID = "axes.W005"
|
|
||||||
LOCKOUT_PARAMETERS_INVALID = "axes.W006"
|
|
||||||
|
|
||||||
|
|
||||||
@register(Tags.security, Tags.caches, Tags.compatibility)
|
@register(Tags.security, Tags.caches, Tags.compatibility)
|
||||||
|
|
@ -162,77 +153,3 @@ def axes_deprecation_check(app_configs, **kwargs): # pylint: disable=unused-arg
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return warnings
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
@register(Tags.security)
|
|
||||||
def axes_lockout_params_check(app_configs, **kwargs): # pylint: disable=unused-argument
|
|
||||||
warnings = []
|
|
||||||
|
|
||||||
lockout_params = getattr(settings, "AXES_LOCKOUT_PARAMETERS", None)
|
|
||||||
|
|
||||||
if isinstance(lockout_params, (list, tuple)):
|
|
||||||
has_ip = False
|
|
||||||
for param in lockout_params:
|
|
||||||
if param == "ip_address":
|
|
||||||
has_ip = True
|
|
||||||
break
|
|
||||||
if isinstance(param, (list, tuple)) and "ip_address" in param:
|
|
||||||
has_ip = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if not has_ip:
|
|
||||||
warnings.append(
|
|
||||||
Warning(
|
|
||||||
msg=Messages.LOCKOUT_PARAMETERS_INVALID,
|
|
||||||
hint=Hints.LOCKOUT_PARAMETERS_INVALID,
|
|
||||||
id=Codes.LOCKOUT_PARAMETERS_INVALID,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return warnings
|
|
||||||
|
|
||||||
|
|
||||||
@register
|
|
||||||
def axes_conf_check(app_configs, **kwargs): # pylint: disable=unused-argument
|
|
||||||
warnings = []
|
|
||||||
|
|
||||||
callable_settings = [
|
|
||||||
"AXES_CLIENT_IP_CALLABLE",
|
|
||||||
"AXES_CLIENT_STR_CALLABLE",
|
|
||||||
"AXES_LOCKOUT_CALLABLE",
|
|
||||||
"AXES_USERNAME_CALLABLE",
|
|
||||||
"AXES_WHITELIST_CALLABLE",
|
|
||||||
"AXES_COOLOFF_TIME",
|
|
||||||
"AXES_LOCKOUT_PARAMETERS",
|
|
||||||
]
|
|
||||||
|
|
||||||
for callable_setting in callable_settings:
|
|
||||||
value = getattr(settings, callable_setting, None)
|
|
||||||
if not is_valid_callable(value):
|
|
||||||
warnings.append(
|
|
||||||
Warning(
|
|
||||||
msg=Messages.CALLABLE_INVALID.format(
|
|
||||||
callable_setting=callable_setting
|
|
||||||
),
|
|
||||||
hint=Hints.CALLABLE_INVALID,
|
|
||||||
id=Codes.CALLABLE_INVALID,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return warnings
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_callable(value) -> bool:
|
|
||||||
if value is None:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if callable(value):
|
|
||||||
return True
|
|
||||||
|
|
||||||
if isinstance(value, str):
|
|
||||||
try:
|
|
||||||
import_string(value)
|
|
||||||
except ImportError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
|
||||||
30
axes/conf.py
30
axes/conf.py
|
|
@ -1,21 +1,6 @@
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.utils.functional import SimpleLazyObject
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
class JSONSerializableLazyObject(SimpleLazyObject):
|
|
||||||
"""
|
|
||||||
Celery/Kombu config inspection may JSON-encode Django settings.
|
|
||||||
Provide a JSON-friendly representation for lazy values.
|
|
||||||
|
|
||||||
Fixes jazzband/django-axes#1391
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __json__(self):
|
|
||||||
return str(self)
|
|
||||||
|
|
||||||
|
|
||||||
# disable plugin when set to False
|
# disable plugin when set to False
|
||||||
settings.AXES_ENABLED = getattr(settings, "AXES_ENABLED", True)
|
settings.AXES_ENABLED = getattr(settings, "AXES_ENABLED", True)
|
||||||
|
|
||||||
|
|
@ -56,16 +41,9 @@ settings.AXES_ONLY_ADMIN_SITE = getattr(settings, "AXES_ONLY_ADMIN_SITE", False)
|
||||||
# show Axes logs in admin
|
# show Axes logs in admin
|
||||||
settings.AXES_ENABLE_ADMIN = getattr(settings, "AXES_ENABLE_ADMIN", True)
|
settings.AXES_ENABLE_ADMIN = getattr(settings, "AXES_ENABLE_ADMIN", True)
|
||||||
|
|
||||||
|
|
||||||
# use a specific username field to retrieve from login POST data
|
# use a specific username field to retrieve from login POST data
|
||||||
def _get_username_field_default():
|
|
||||||
return get_user_model().USERNAME_FIELD
|
|
||||||
|
|
||||||
|
|
||||||
settings.AXES_USERNAME_FORM_FIELD = getattr(
|
settings.AXES_USERNAME_FORM_FIELD = getattr(
|
||||||
settings,
|
settings, "AXES_USERNAME_FORM_FIELD", "username"
|
||||||
"AXES_USERNAME_FORM_FIELD",
|
|
||||||
JSONSerializableLazyObject(_get_username_field_default),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# use a specific password field to retrieve from login POST data
|
# use a specific password field to retrieve from login POST data
|
||||||
|
|
@ -108,10 +86,6 @@ settings.AXES_LOCKOUT_URL = getattr(settings, "AXES_LOCKOUT_URL", None)
|
||||||
|
|
||||||
settings.AXES_COOLOFF_TIME = getattr(settings, "AXES_COOLOFF_TIME", None)
|
settings.AXES_COOLOFF_TIME = getattr(settings, "AXES_COOLOFF_TIME", None)
|
||||||
|
|
||||||
settings.AXES_USE_ATTEMPT_EXPIRATION = getattr(
|
|
||||||
settings, "AXES_USE_ATTEMPT_EXPIRATION", False
|
|
||||||
)
|
|
||||||
|
|
||||||
settings.AXES_VERBOSE = getattr(settings, "AXES_VERBOSE", settings.AXES_ENABLED)
|
settings.AXES_VERBOSE = getattr(settings, "AXES_VERBOSE", settings.AXES_ENABLED)
|
||||||
|
|
||||||
# whitelist and blacklist
|
# whitelist and blacklist
|
||||||
|
|
@ -150,7 +124,7 @@ settings.AXES_ALLOWED_CORS_ORIGINS = getattr(settings, "AXES_ALLOWED_CORS_ORIGIN
|
||||||
settings.AXES_SENSITIVE_PARAMETERS = getattr(
|
settings.AXES_SENSITIVE_PARAMETERS = getattr(
|
||||||
settings,
|
settings,
|
||||||
"AXES_SENSITIVE_PARAMETERS",
|
"AXES_SENSITIVE_PARAMETERS",
|
||||||
["username", "ip_address"],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
# set the callable for the readable string that can be used in
|
# set the callable for the readable string that can be used in
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
return
|
return
|
||||||
|
|
||||||
cache_keys = get_client_cache_keys(request, credentials)
|
cache_keys = get_client_cache_keys(request, credentials)
|
||||||
cache_timeout = get_cache_timeout(request)
|
cache_timeout = get_cache_timeout()
|
||||||
failures = []
|
failures = []
|
||||||
for cache_key in cache_keys:
|
for cache_key in cache_keys:
|
||||||
added = self.cache.add(key=cache_key, value=1, timeout=cache_timeout)
|
added = self.cache.add(key=cache_key, value=1, timeout=cache_timeout)
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,27 @@
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from django.db import router, transaction
|
from django.db import transaction
|
||||||
from django.db.models import F, Q, QuerySet, Sum, Value
|
from django.db.models import F, Sum, Value, Q
|
||||||
from django.db.models.functions import Concat
|
from django.db.models.functions import Concat
|
||||||
from django.http import HttpRequest
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from axes.attempts import 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.conf import settings
|
||||||
from axes.handlers.base import AbstractAxesHandler, AxesBaseHandler
|
from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler
|
||||||
from axes.helpers import (
|
from axes.helpers import (
|
||||||
get_client_parameters,
|
|
||||||
get_client_session_hash,
|
|
||||||
get_client_str,
|
get_client_str,
|
||||||
get_client_username,
|
get_client_username,
|
||||||
get_credentials,
|
get_credentials,
|
||||||
get_failure_limit,
|
get_failure_limit,
|
||||||
get_lockout_parameters,
|
get_lockout_parameters,
|
||||||
get_query_str,
|
get_query_str,
|
||||||
get_attempt_expiration,
|
|
||||||
)
|
|
||||||
from axes.models import (
|
|
||||||
AccessAttempt,
|
|
||||||
AccessAttemptExpiration,
|
|
||||||
AccessFailureLog,
|
|
||||||
AccessLog,
|
|
||||||
)
|
)
|
||||||
|
from axes.models import AccessLog, AccessAttempt, AccessFailureLog
|
||||||
from axes.signals import user_locked_out
|
from axes.signals import user_locked_out
|
||||||
|
|
||||||
log = getLogger(__name__)
|
log = getLogger(__name__)
|
||||||
|
|
@ -108,7 +103,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def get_failures(self, request, credentials: Optional[dict] = None) -> int:
|
def get_failures(self, request, credentials: Optional[dict] = None) -> int:
|
||||||
attempts_list = self.get_user_attempts(request, credentials)
|
attempts_list = get_user_attempts(request, credentials)
|
||||||
attempt_count = max(
|
attempt_count = max(
|
||||||
(
|
(
|
||||||
attempts.aggregate(Sum("failures_since_start"))[
|
attempts.aggregate(Sum("failures_since_start"))[
|
||||||
|
|
@ -121,10 +116,10 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
return attempt_count
|
return attempt_count
|
||||||
|
|
||||||
def user_login_failed(self, sender, credentials: dict, request=None, **kwargs):
|
def user_login_failed(self, sender, credentials: dict, request=None, **kwargs):
|
||||||
"""
|
"""When user login fails, save AccessFailureLog record in database,
|
||||||
When user login fails, save AccessFailureLog record in database,
|
|
||||||
save AccessAttempt record in database, mark request with
|
save AccessAttempt record in database, mark request with
|
||||||
lockout attribute and emit lockout signal.
|
lockout attribute and emit lockout signal.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
log.info("AXES: User login failed, running database handler for failure.")
|
log.info("AXES: User login failed, running database handler for failure.")
|
||||||
|
|
@ -136,7 +131,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
return
|
return
|
||||||
|
|
||||||
# 1. database query: Clean up expired user attempts from the database before logging new attempts
|
# 1. database query: Clean up expired user attempts from the database before logging new attempts
|
||||||
self.clean_expired_user_attempts(request, credentials)
|
clean_expired_user_attempts(request.axes_attempt_time)
|
||||||
|
|
||||||
username = get_client_username(request, credentials)
|
username = get_client_username(request, credentials)
|
||||||
client_str = get_client_str(
|
client_str = get_client_str(
|
||||||
|
|
@ -176,7 +171,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
"AXES: Username is None and username is the only one lockout parameter, new record will NOT be created."
|
"AXES: Username is None and username is the only one lockout parameter, new record will NOT be created."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
with transaction.atomic(using=router.db_for_write(AccessAttempt)):
|
with transaction.atomic():
|
||||||
(
|
(
|
||||||
attempt,
|
attempt,
|
||||||
created,
|
created,
|
||||||
|
|
@ -225,23 +220,6 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
client_str,
|
client_str,
|
||||||
)
|
)
|
||||||
|
|
||||||
if settings.AXES_USE_ATTEMPT_EXPIRATION:
|
|
||||||
if not hasattr(attempt, "expiration") or attempt.expiration is None:
|
|
||||||
log.debug(
|
|
||||||
"AXES: Creating new AccessAttemptExpiration for %s",
|
|
||||||
client_str,
|
|
||||||
)
|
|
||||||
attempt.expiration = AccessAttemptExpiration.objects.create(
|
|
||||||
access_attempt=attempt,
|
|
||||||
expires_at=get_attempt_expiration(request),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
attempt.expiration.expires_at = max(
|
|
||||||
get_attempt_expiration(request),
|
|
||||||
attempt.expiration.expires_at,
|
|
||||||
)
|
|
||||||
attempt.expiration.save()
|
|
||||||
|
|
||||||
# 3. or 4. database query: Calculate the current maximum failure number from the existing attempts
|
# 3. or 4. database query: Calculate the current maximum failure number from the existing attempts
|
||||||
failures_since_start = self.get_failures(request, credentials)
|
failures_since_start = self.get_failures(request, credentials)
|
||||||
request.axes_failures_since_start = failures_since_start
|
request.axes_failures_since_start = failures_since_start
|
||||||
|
|
@ -265,7 +243,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
|
|
||||||
# 5. database entry: Log for ever the attempt in the AccessFailureLog
|
# 5. database entry: Log for ever the attempt in the AccessFailureLog
|
||||||
if settings.AXES_ENABLE_ACCESS_FAILURE_LOG:
|
if settings.AXES_ENABLE_ACCESS_FAILURE_LOG:
|
||||||
with transaction.atomic(using=router.db_for_write(AccessFailureLog)):
|
with transaction.atomic():
|
||||||
AccessFailureLog.objects.create(
|
AccessFailureLog.objects.create(
|
||||||
username=username,
|
username=username,
|
||||||
ip_address=request.axes_ip_address,
|
ip_address=request.axes_ip_address,
|
||||||
|
|
@ -282,6 +260,9 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
When user logs in, update the AccessLog related to the user.
|
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()
|
username = user.get_username()
|
||||||
credentials = get_credentials(username)
|
credentials = get_credentials(username)
|
||||||
client_str = get_client_str(
|
client_str = get_client_str(
|
||||||
|
|
@ -294,9 +275,6 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
|
|
||||||
log.info("AXES: Successful login by %s.", client_str)
|
log.info("AXES: Successful login by %s.", client_str)
|
||||||
|
|
||||||
# 1. database query: Clean up expired user attempts from the database
|
|
||||||
self.clean_expired_user_attempts(request, credentials)
|
|
||||||
|
|
||||||
if not settings.AXES_DISABLE_ACCESS_LOG:
|
if not settings.AXES_DISABLE_ACCESS_LOG:
|
||||||
# 2. database query: Insert new access logs with login time
|
# 2. database query: Insert new access logs with login time
|
||||||
AccessLog.objects.create(
|
AccessLog.objects.create(
|
||||||
|
|
@ -306,14 +284,11 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
http_accept=request.axes_http_accept,
|
http_accept=request.axes_http_accept,
|
||||||
path_info=request.axes_path_info,
|
path_info=request.axes_path_info,
|
||||||
attempt_time=request.axes_attempt_time,
|
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:
|
if settings.AXES_RESET_ON_SUCCESS:
|
||||||
# 3. database query: Reset failed attempts for the logging in user
|
# 3. database query: Reset failed attempts for the logging in user
|
||||||
count = self.reset_user_attempts(request, credentials)
|
count = reset_user_attempts(request, credentials)
|
||||||
log.info(
|
log.info(
|
||||||
"AXES: Deleted %d failed login attempts by %s from database.",
|
"AXES: Deleted %d failed login attempts by %s from database.",
|
||||||
count,
|
count,
|
||||||
|
|
@ -325,8 +300,10 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
When user logs out, update the AccessLog related to the user.
|
When user logs out, update the AccessLog related to the user.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# 1. database query: Clean up expired user attempts from the database
|
||||||
|
clean_expired_user_attempts(request.axes_attempt_time)
|
||||||
|
|
||||||
username = user.get_username() if user else None
|
username = user.get_username() if user else None
|
||||||
credentials = get_credentials(username) if username else None
|
|
||||||
client_str = get_client_str(
|
client_str = get_client_str(
|
||||||
username,
|
username,
|
||||||
request.axes_ip_address,
|
request.axes_ip_address,
|
||||||
|
|
@ -335,117 +312,14 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
request,
|
request,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 1. database query: Clean up expired user attempts from the database
|
|
||||||
self.clean_expired_user_attempts(request, credentials)
|
|
||||||
|
|
||||||
log.info("AXES: Successful logout by %s.", client_str)
|
log.info("AXES: Successful logout by %s.", client_str)
|
||||||
|
|
||||||
if username and not settings.AXES_DISABLE_ACCESS_LOG:
|
if username and not settings.AXES_DISABLE_ACCESS_LOG:
|
||||||
# 2. database query: Update existing attempt logs with logout time
|
# 2. database query: Update existing attempt logs with logout time
|
||||||
AccessLog.objects.filter(
|
AccessLog.objects.filter(
|
||||||
username=username,
|
username=username, logout_time__isnull=True
|
||||||
logout_time__isnull=True,
|
|
||||||
# update only access log for given session
|
|
||||||
session_hash=get_client_session_hash(request),
|
|
||||||
).update(logout_time=request.axes_attempt_time)
|
).update(logout_time=request.axes_attempt_time)
|
||||||
|
|
||||||
def filter_user_attempts(
|
|
||||||
self, request: HttpRequest, credentials: Optional[dict] = None
|
|
||||||
) -> List[QuerySet]:
|
|
||||||
"""
|
|
||||||
Return a list querysets of AccessAttempts that match the given request and credentials.
|
|
||||||
"""
|
|
||||||
|
|
||||||
username = get_client_username(request, credentials)
|
|
||||||
|
|
||||||
filter_kwargs_list = get_client_parameters(
|
|
||||||
username,
|
|
||||||
request.axes_ip_address,
|
|
||||||
request.axes_user_agent,
|
|
||||||
request,
|
|
||||||
credentials,
|
|
||||||
)
|
|
||||||
attempts_list = [
|
|
||||||
AccessAttempt.objects.filter(**filter_kwargs)
|
|
||||||
for filter_kwargs in filter_kwargs_list
|
|
||||||
]
|
|
||||||
return attempts_list
|
|
||||||
|
|
||||||
def get_user_attempts(
|
|
||||||
self, request: HttpRequest, credentials: Optional[dict] = None # noqa
|
|
||||||
) -> List[QuerySet]:
|
|
||||||
"""
|
|
||||||
Get list of querysets with valid user attempts that match the given request and credentials.
|
|
||||||
"""
|
|
||||||
|
|
||||||
attempts_list = self.filter_user_attempts(request, credentials)
|
|
||||||
|
|
||||||
if settings.AXES_COOLOFF_TIME is None:
|
|
||||||
log.debug(
|
|
||||||
"AXES: Getting all access attempts from database because no AXES_COOLOFF_TIME is configured"
|
|
||||||
)
|
|
||||||
return attempts_list
|
|
||||||
|
|
||||||
threshold = get_cool_off_threshold(request)
|
|
||||||
log.debug("AXES: Getting access attempts that are newer than %s", threshold)
|
|
||||||
return [
|
|
||||||
attempts.filter(attempt_time__gte=threshold) for attempts in attempts_list
|
|
||||||
]
|
|
||||||
|
|
||||||
def clean_expired_user_attempts(
|
|
||||||
self,
|
|
||||||
request: Optional[HttpRequest] = None,
|
|
||||||
credentials: Optional[dict] = None, # noqa
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
Clean expired user attempts from the database.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if settings.AXES_COOLOFF_TIME is None:
|
|
||||||
log.debug(
|
|
||||||
"AXES: Skipping clean for expired access attempts because no AXES_COOLOFF_TIME is configured"
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if settings.AXES_USE_ATTEMPT_EXPIRATION:
|
|
||||||
threshold = timezone.now()
|
|
||||||
count, _ = AccessAttempt.objects.filter(
|
|
||||||
expiration__expires_at__lte=threshold
|
|
||||||
).delete()
|
|
||||||
log.info(
|
|
||||||
"AXES: Cleaned up %s expired access attempts from database that expiry were older than %s",
|
|
||||||
count,
|
|
||||||
threshold,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
threshold = get_cool_off_threshold(request)
|
|
||||||
count, _ = AccessAttempt.objects.filter(
|
|
||||||
attempt_time__lte=threshold
|
|
||||||
).delete()
|
|
||||||
log.info(
|
|
||||||
"AXES: Cleaned up %s expired access attempts from database that were older than %s",
|
|
||||||
count,
|
|
||||||
threshold,
|
|
||||||
)
|
|
||||||
return count
|
|
||||||
|
|
||||||
def reset_user_attempts(
|
|
||||||
self, request: HttpRequest, credentials: Optional[dict] = None
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
Reset all user attempts that match the given request and credentials.
|
|
||||||
"""
|
|
||||||
|
|
||||||
attempts_list = self.filter_user_attempts(request, credentials)
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
for attempts in attempts_list:
|
|
||||||
_count, _ = attempts.delete()
|
|
||||||
count += _count
|
|
||||||
log.info("AXES: Reset %s access attempts from database.", count)
|
|
||||||
|
|
||||||
return count
|
|
||||||
|
|
||||||
def post_save_access_attempt(self, instance, **kwargs):
|
def post_save_access_attempt(self, instance, **kwargs):
|
||||||
"""
|
"""
|
||||||
Handles the ``axes.models.AccessAttempt`` object post save signal.
|
Handles the ``axes.models.AccessAttempt`` object post save signal.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from datetime import timedelta, datetime
|
from datetime import timedelta
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from string import Template
|
from string import Template
|
||||||
|
|
@ -8,7 +8,6 @@ from urllib.parse import urlencode
|
||||||
from django.core.cache import BaseCache, caches
|
from django.core.cache import BaseCache, caches
|
||||||
from django.http import HttpRequest, HttpResponse, JsonResponse, QueryDict
|
from django.http import HttpRequest, HttpResponse, JsonResponse, QueryDict
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
from django.utils.encoding import force_bytes
|
|
||||||
from django.utils.module_loading import import_string
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
from axes.conf import settings
|
from axes.conf import settings
|
||||||
|
|
@ -32,33 +31,32 @@ def get_cache() -> BaseCache:
|
||||||
return caches[getattr(settings, "AXES_CACHE", "default")]
|
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.
|
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.
|
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
|
and this function offers a unified _integer or None_ representation of that configuration
|
||||||
for use with the Django cache backends.
|
for use with the Django cache backends.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
cool_off = get_cool_off(request)
|
cool_off = get_cool_off()
|
||||||
if cool_off is None:
|
if cool_off is None:
|
||||||
return None
|
return None
|
||||||
return int(cool_off.total_seconds())
|
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.
|
Return the login cool off time interpreted from settings.AXES_COOLOFF_TIME.
|
||||||
|
|
||||||
The return value is either None or timedelta.
|
The return value is either None or timedelta.
|
||||||
|
|
||||||
Notice that the settings.AXES_COOLOFF_TIME is either None, timedelta, integer/float of hours,
|
Notice that the settings.AXES_COOLOFF_TIME is either None, timedelta, or integer of hours,
|
||||||
a path to a callable or a callable taking 1 argument (the request). This function
|
and this function offers a unified _timedelta or None_ representation of that configuration
|
||||||
offers a unified _timedelta or None_ representation of that configuration for use with the
|
for use with the Axes internal implementations.
|
||||||
Axes internal implementations.
|
|
||||||
|
|
||||||
:exception TypeError: if settings.AXES_COOLOFF_TIME is of wrong type.
|
: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):
|
if isinstance(cool_off, float):
|
||||||
return timedelta(minutes=cool_off * 60)
|
return timedelta(minutes=cool_off * 60)
|
||||||
if isinstance(cool_off, str):
|
if isinstance(cool_off, str):
|
||||||
cool_off_func = import_string(cool_off)
|
return import_string(cool_off)()
|
||||||
return cool_off_func(request)
|
|
||||||
if callable(cool_off):
|
if callable(cool_off):
|
||||||
return cool_off(request) # pylint: disable=not-callable
|
return cool_off() # pylint: disable=not-callable
|
||||||
|
|
||||||
return cool_off
|
return cool_off
|
||||||
|
|
||||||
|
|
@ -101,23 +98,6 @@ def get_cool_off_iso8601(delta: timedelta) -> str:
|
||||||
return f"P{days_str}"
|
return f"P{days_str}"
|
||||||
|
|
||||||
|
|
||||||
def get_attempt_expiration(request: Optional[HttpRequest] = None) -> datetime:
|
|
||||||
"""
|
|
||||||
Get threshold for fetching access attempts from the database.
|
|
||||||
"""
|
|
||||||
|
|
||||||
cool_off = get_cool_off(request)
|
|
||||||
if cool_off is None:
|
|
||||||
raise TypeError(
|
|
||||||
"Cool off threshold can not be calculated with settings.AXES_COOLOFF_TIME set to None"
|
|
||||||
)
|
|
||||||
|
|
||||||
attempt_time = request.axes_attempt_time # type: ignore[union-attr]
|
|
||||||
if attempt_time is None:
|
|
||||||
return datetime.now() + cool_off
|
|
||||||
return attempt_time + cool_off
|
|
||||||
|
|
||||||
|
|
||||||
def get_credentials(username: Optional[str] = None, **kwargs) -> dict:
|
def get_credentials(username: Optional[str] = None, **kwargs) -> dict:
|
||||||
"""
|
"""
|
||||||
Calculate credentials for Axes to use internally from given username and kwargs.
|
Calculate credentials for Axes to use internally from given username and kwargs.
|
||||||
|
|
@ -164,7 +144,7 @@ def get_client_username(
|
||||||
log.debug(
|
log.debug(
|
||||||
"Using parameter credentials to get username with key settings.AXES_USERNAME_FORM_FIELD"
|
"Using parameter credentials to get username with key settings.AXES_USERNAME_FORM_FIELD"
|
||||||
)
|
)
|
||||||
return credentials.get(settings.AXES_USERNAME_FORM_FIELD, None) # type: ignore[return-value]
|
return credentials.get(settings.AXES_USERNAME_FORM_FIELD, None)
|
||||||
|
|
||||||
log.debug(
|
log.debug(
|
||||||
"Using parameter request.POST to get username with key settings.AXES_USERNAME_FORM_FIELD"
|
"Using parameter request.POST to get username with key settings.AXES_USERNAME_FORM_FIELD"
|
||||||
|
|
@ -462,27 +442,15 @@ def get_lockout_message() -> str:
|
||||||
|
|
||||||
|
|
||||||
def get_lockout_response(
|
def get_lockout_response(
|
||||||
request: HttpRequest,
|
request: HttpRequest, credentials: Optional[dict] = None
|
||||||
original_response: Optional[HttpResponse] = None,
|
|
||||||
credentials: Optional[dict] = None,
|
|
||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
if settings.AXES_LOCKOUT_CALLABLE:
|
if settings.AXES_LOCKOUT_CALLABLE:
|
||||||
if callable(settings.AXES_LOCKOUT_CALLABLE):
|
if callable(settings.AXES_LOCKOUT_CALLABLE):
|
||||||
# Try calling with 3 args, fallback to 2 for backward compatibility
|
return settings.AXES_LOCKOUT_CALLABLE( # pylint: disable=not-callable
|
||||||
try:
|
request, credentials
|
||||||
return settings.AXES_LOCKOUT_CALLABLE(
|
)
|
||||||
request, original_response, credentials
|
|
||||||
)
|
|
||||||
except TypeError:
|
|
||||||
# Fallback: old signature without original_response
|
|
||||||
return settings.AXES_LOCKOUT_CALLABLE(request, credentials)
|
|
||||||
if isinstance(settings.AXES_LOCKOUT_CALLABLE, str):
|
if isinstance(settings.AXES_LOCKOUT_CALLABLE, str):
|
||||||
callable_obj = import_string(settings.AXES_LOCKOUT_CALLABLE)
|
return import_string(settings.AXES_LOCKOUT_CALLABLE)(request, credentials)
|
||||||
# Try calling with 3 args, fallback to 2 for backward compatibility
|
|
||||||
try:
|
|
||||||
return callable_obj(request, original_response, credentials)
|
|
||||||
except TypeError:
|
|
||||||
return callable_obj(request, credentials)
|
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
"settings.AXES_LOCKOUT_CALLABLE needs to be a string, callable, or None."
|
"settings.AXES_LOCKOUT_CALLABLE needs to be a string, callable, or None."
|
||||||
)
|
)
|
||||||
|
|
@ -493,7 +461,7 @@ def get_lockout_response(
|
||||||
"username": get_client_username(request, credentials) or "",
|
"username": get_client_username(request, credentials) or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
cool_off = get_cool_off(request)
|
cool_off = get_cool_off()
|
||||||
if cool_off:
|
if cool_off:
|
||||||
context.update(
|
context.update(
|
||||||
{
|
{
|
||||||
|
|
@ -506,13 +474,13 @@ def get_lockout_response(
|
||||||
|
|
||||||
if request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest":
|
if request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest":
|
||||||
json_response = JsonResponse(context, status=status)
|
json_response = JsonResponse(context, status=status)
|
||||||
json_response["Access-Control-Allow-Origin"] = (
|
json_response[
|
||||||
settings.AXES_ALLOWED_CORS_ORIGINS
|
"Access-Control-Allow-Origin"
|
||||||
)
|
] = settings.AXES_ALLOWED_CORS_ORIGINS
|
||||||
json_response["Access-Control-Allow-Methods"] = "POST, OPTIONS"
|
json_response["Access-Control-Allow-Methods"] = "POST, OPTIONS"
|
||||||
json_response["Access-Control-Allow-Headers"] = (
|
json_response[
|
||||||
"Origin, Content-Type, Accept, Authorization, x-requested-with"
|
"Access-Control-Allow-Headers"
|
||||||
)
|
] = "Origin, Content-Type, Accept, Authorization, x-requested-with"
|
||||||
return json_response
|
return json_response
|
||||||
|
|
||||||
if settings.AXES_LOCKOUT_TEMPLATE:
|
if settings.AXES_LOCKOUT_TEMPLATE:
|
||||||
|
|
@ -646,24 +614,3 @@ def toggleable(func) -> Callable:
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
return inner
|
return inner
|
||||||
|
|
||||||
|
|
||||||
def get_client_session_hash(request: HttpRequest) -> str:
|
|
||||||
"""
|
|
||||||
Get client session and returns the SHA256 hash of session key, forcing session creation if required.
|
|
||||||
|
|
||||||
If no session is available on request returns an empty string.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
session = request.session
|
|
||||||
except AttributeError:
|
|
||||||
# when no session is available just return an empty string
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# ensure that a session key exists at this point
|
|
||||||
# because session middleware usually creates the session key at the end
|
|
||||||
# of request cycle
|
|
||||||
if session.session_key is None:
|
|
||||||
session.create()
|
|
||||||
|
|
||||||
return sha256(force_bytes(session.session_key)).hexdigest()
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,109 +0,0 @@
|
||||||
# ترجمه فارسی برای django-axes
|
|
||||||
# Copyright (C) 2025 jazzband
|
|
||||||
# This file is distributed under the same license as the django-axes package.
|
|
||||||
# AmirAli Bahramjerdi <amiralibahramjerdi@gmail.com>, 2025.
|
|
||||||
#
|
|
||||||
#, fuzzy
|
|
||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: django-axes\n"
|
|
||||||
"Report-Msgid-Bugs-To: \n"
|
|
||||||
"POT-Creation-Date: 2025-05-16 23:28+0330\n"
|
|
||||||
"PO-Revision-Date: 2025-05-16 23:30+0330\n"
|
|
||||||
"Last-Translator: AmirAli Bahramjerdi <amiralibahramjerdi@gmail.com>"
|
|
||||||
"Language-Team: فارسی <fa@li.org>\n"
|
|
||||||
"Language: fa\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
|
||||||
|
|
||||||
#: admin.py:27
|
|
||||||
msgid "Form Data"
|
|
||||||
msgstr "دادههای فرم"
|
|
||||||
|
|
||||||
#: admin.py:28 admin.py:65 admin.py:100
|
|
||||||
msgid "Meta Data"
|
|
||||||
msgstr "فراداده"
|
|
||||||
|
|
||||||
#: conf.py:109
|
|
||||||
msgid "Account locked: too many login attempts. Please try again later."
|
|
||||||
msgstr "حساب کاربری قفل شد: تلاشهای زیادی برای ورود انجام شده است. لطفاً بعداً دوباره امتحان کنید."
|
|
||||||
|
|
||||||
#: conf.py:117
|
|
||||||
msgid ""
|
|
||||||
"Account locked: too many login attempts. Contact an admin to unlock your "
|
|
||||||
"account."
|
|
||||||
msgstr "حساب کاربری قفل شد: تلاشهای زیادی برای ورود انجام شده است. برای باز کردن حساب با مدیر تماس بگیرید."
|
|
||||||
|
|
||||||
#: models.py:6
|
|
||||||
msgid "User Agent"
|
|
||||||
msgstr "عامل کاربر (User Agent)"
|
|
||||||
|
|
||||||
#: models.py:8
|
|
||||||
msgid "IP Address"
|
|
||||||
msgstr "آدرس IP"
|
|
||||||
|
|
||||||
#: models.py:10
|
|
||||||
msgid "Username"
|
|
||||||
msgstr "نام کاربری"
|
|
||||||
|
|
||||||
#: models.py:12
|
|
||||||
msgid "HTTP Accept"
|
|
||||||
msgstr "پذیرش HTTP"
|
|
||||||
|
|
||||||
#: models.py:14
|
|
||||||
msgid "Path"
|
|
||||||
msgstr "مسیر"
|
|
||||||
|
|
||||||
#: models.py:16
|
|
||||||
msgid "Attempt Time"
|
|
||||||
msgstr "زمان تلاش"
|
|
||||||
|
|
||||||
#: models.py:26
|
|
||||||
msgid "Access lock out"
|
|
||||||
msgstr "قفل دسترسی"
|
|
||||||
|
|
||||||
#: models.py:34
|
|
||||||
msgid "access failure"
|
|
||||||
msgstr "شکست در دسترسی"
|
|
||||||
|
|
||||||
#: models.py:35
|
|
||||||
msgid "access failures"
|
|
||||||
msgstr "شکستهای دسترسی"
|
|
||||||
|
|
||||||
#: models.py:39
|
|
||||||
msgid "GET Data"
|
|
||||||
msgstr "دادههای GET"
|
|
||||||
|
|
||||||
#: models.py:41
|
|
||||||
msgid "POST Data"
|
|
||||||
msgstr "دادههای POST"
|
|
||||||
|
|
||||||
#: models.py:43
|
|
||||||
msgid "Failed Logins"
|
|
||||||
msgstr "ورودهای ناموفق"
|
|
||||||
|
|
||||||
#: models.py:49
|
|
||||||
msgid "access attempt"
|
|
||||||
msgstr "تلاش برای دسترسی"
|
|
||||||
|
|
||||||
#: models.py:50
|
|
||||||
msgid "access attempts"
|
|
||||||
msgstr "تلاشهای دسترسی"
|
|
||||||
|
|
||||||
#: models.py:55
|
|
||||||
msgid "Logout Time"
|
|
||||||
msgstr "زمان خروج"
|
|
||||||
|
|
||||||
#: models.py:56
|
|
||||||
msgid "Session key hash (sha256)"
|
|
||||||
msgstr "هش کلید نشست (sha256)"
|
|
||||||
|
|
||||||
#: models.py:62
|
|
||||||
msgid "access log"
|
|
||||||
msgstr "گزارش دسترسی"
|
|
||||||
|
|
||||||
#: models.py:63
|
|
||||||
msgid "access logs"
|
|
||||||
msgstr "گزارشهای دسترسی"
|
|
||||||
Binary file not shown.
|
|
@ -1,109 +0,0 @@
|
||||||
# SOME DESCRIPTIVE TITLE.
|
|
||||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
|
||||||
# This file is distributed under the same license as the PACKAGE package.
|
|
||||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
|
||||||
#
|
|
||||||
#, fuzzy
|
|
||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
|
||||||
"Report-Msgid-Bugs-To: \n"
|
|
||||||
"POT-Creation-Date: 2023-11-06 05:21-0600\n"
|
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
|
||||||
"Language: \n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
|
||||||
|
|
||||||
#: admin.py:27
|
|
||||||
msgid "Form Data"
|
|
||||||
msgstr "Données de formulaire"
|
|
||||||
|
|
||||||
#: admin.py:28 admin.py:65 admin.py:100
|
|
||||||
msgid "Meta Data"
|
|
||||||
msgstr "Métadonnées"
|
|
||||||
|
|
||||||
#: conf.py:108
|
|
||||||
msgid "Account locked: too many login attempts. Please try again later."
|
|
||||||
msgstr ""
|
|
||||||
"Compte verrouillé: trop de tentatives de connexion. Veuillez réessayer plus "
|
|
||||||
"tard."
|
|
||||||
|
|
||||||
#: conf.py:116
|
|
||||||
msgid ""
|
|
||||||
"Account locked: too many login attempts. Contact an admin to unlock your "
|
|
||||||
"account."
|
|
||||||
msgstr ""
|
|
||||||
"Compte verrouillé: trop de tentatives de connexion. Contactez un "
|
|
||||||
"administrateur pour déverrouiller votre compte."
|
|
||||||
|
|
||||||
#: models.py:6
|
|
||||||
msgid "User Agent"
|
|
||||||
msgstr "User Agent"
|
|
||||||
|
|
||||||
#: models.py:8
|
|
||||||
msgid "IP Address"
|
|
||||||
msgstr "Adresse IP"
|
|
||||||
|
|
||||||
#: models.py:10
|
|
||||||
msgid "Username"
|
|
||||||
msgstr "Nom d'utilisateur"
|
|
||||||
|
|
||||||
#: models.py:12
|
|
||||||
msgid "HTTP Accept"
|
|
||||||
msgstr "HTTP Accept"
|
|
||||||
|
|
||||||
#: models.py:14
|
|
||||||
msgid "Path"
|
|
||||||
msgstr "Chemin"
|
|
||||||
|
|
||||||
#: models.py:16
|
|
||||||
msgid "Attempt Time"
|
|
||||||
msgstr "Date de la tentative"
|
|
||||||
|
|
||||||
#: models.py:26
|
|
||||||
msgid "Access lock out"
|
|
||||||
msgstr "Verrouillage de l'accès"
|
|
||||||
|
|
||||||
#: models.py:34
|
|
||||||
msgid "access failure"
|
|
||||||
msgstr "échec de connexion"
|
|
||||||
|
|
||||||
#: models.py:35
|
|
||||||
msgid "access failures"
|
|
||||||
msgstr "échecs de connexion"
|
|
||||||
|
|
||||||
#: models.py:39
|
|
||||||
msgid "GET Data"
|
|
||||||
msgstr "Données GET"
|
|
||||||
|
|
||||||
#: models.py:41
|
|
||||||
msgid "POST Data"
|
|
||||||
msgstr "Données POST"
|
|
||||||
|
|
||||||
#: models.py:43
|
|
||||||
msgid "Failed Logins"
|
|
||||||
msgstr "Nombre d'échecs"
|
|
||||||
|
|
||||||
#: models.py:49
|
|
||||||
msgid "access attempt"
|
|
||||||
msgstr "tentative de connexion"
|
|
||||||
|
|
||||||
#: models.py:50
|
|
||||||
msgid "access attempts"
|
|
||||||
msgstr "tentatives de connexion"
|
|
||||||
|
|
||||||
#: models.py:55
|
|
||||||
msgid "Logout Time"
|
|
||||||
msgstr "Date de la déconnexion"
|
|
||||||
|
|
||||||
#: models.py:61
|
|
||||||
msgid "access log"
|
|
||||||
msgstr "connexion"
|
|
||||||
|
|
||||||
#: models.py:62
|
|
||||||
msgid "access logs"
|
|
||||||
msgstr "connexions"
|
|
||||||
Binary file not shown.
|
|
@ -1,105 +0,0 @@
|
||||||
# Indonesian translation for django-axes.
|
|
||||||
# Copyright (C) 2023
|
|
||||||
# This file is distributed under the same license as the django-axes package.
|
|
||||||
# Kira <kiraware@github.com>, 2023.
|
|
||||||
#
|
|
||||||
#, fuzzy
|
|
||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: django-axes 6.0.4\n"
|
|
||||||
"Report-Msgid-Bugs-To: \n"
|
|
||||||
"POT-Creation-Date: 2023-06-30 09:21+0800\n"
|
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
|
||||||
"Last-Translator: Kira <kiraware@github.com>\n"
|
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
|
||||||
"Language: id\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
|
||||||
#: .\axes\admin.py:27
|
|
||||||
msgid "Form Data"
|
|
||||||
msgstr "Data Formulir"
|
|
||||||
|
|
||||||
#: .\axes\admin.py:28 .\axes\admin.py:65 .\axes\admin.py:100
|
|
||||||
msgid "Meta Data"
|
|
||||||
msgstr "Meta Data"
|
|
||||||
|
|
||||||
#: .\axes\conf.py:108
|
|
||||||
msgid "Account locked: too many login attempts. Please try again later."
|
|
||||||
msgstr "Akun terkunci: terlalu banyak percobaan login. Silakan coba lagi nanti."
|
|
||||||
|
|
||||||
#: .\axes\conf.py:116
|
|
||||||
msgid ""
|
|
||||||
"Account locked: too many login attempts. Contact an admin to unlock your "
|
|
||||||
"account."
|
|
||||||
msgstr "Akun terkunci: terlalu banyak percobaan login. Hubungi admin untuk"
|
|
||||||
" membuka kunci akun"
|
|
||||||
|
|
||||||
#: .\axes\models.py:6
|
|
||||||
msgid "User Agent"
|
|
||||||
msgstr "User Agent"
|
|
||||||
|
|
||||||
#: .\axes\models.py:8
|
|
||||||
msgid "IP Address"
|
|
||||||
msgstr "Alamat IP"
|
|
||||||
|
|
||||||
#: .\axes\models.py:10
|
|
||||||
msgid "Username"
|
|
||||||
msgstr "Nama Pengguna"
|
|
||||||
|
|
||||||
#: .\axes\models.py:12
|
|
||||||
msgid "HTTP Accept"
|
|
||||||
msgstr "HTTP Accept"
|
|
||||||
|
|
||||||
#: .\axes\models.py:14
|
|
||||||
msgid "Path"
|
|
||||||
msgstr "Path"
|
|
||||||
|
|
||||||
#: .\axes\models.py:16
|
|
||||||
msgid "Attempt Time"
|
|
||||||
msgstr "Waktu Percobaan"
|
|
||||||
|
|
||||||
#: .\axes\models.py:26
|
|
||||||
msgid "Access lock out"
|
|
||||||
msgstr "Akses terkunci"
|
|
||||||
|
|
||||||
#: .\axes\models.py:34
|
|
||||||
msgid "access failure"
|
|
||||||
msgstr "kegagalan akses"
|
|
||||||
|
|
||||||
#: .\axes\models.py:35
|
|
||||||
msgid "access failures"
|
|
||||||
msgstr "kegagalan akses"
|
|
||||||
|
|
||||||
#: .\axes\models.py:39
|
|
||||||
msgid "GET Data"
|
|
||||||
msgstr "Data GET"
|
|
||||||
|
|
||||||
#: .\axes\models.py:41
|
|
||||||
msgid "POST Data"
|
|
||||||
msgstr "Data POST"
|
|
||||||
|
|
||||||
#: .\axes\models.py:43
|
|
||||||
msgid "Failed Logins"
|
|
||||||
msgstr "Login Gagal"
|
|
||||||
|
|
||||||
#: .\axes\models.py:49
|
|
||||||
msgid "access attempt"
|
|
||||||
msgstr "upaya akses"
|
|
||||||
|
|
||||||
#: .\axes\models.py:50
|
|
||||||
msgid "access attempts"
|
|
||||||
msgstr "upaya akses"
|
|
||||||
|
|
||||||
#: .\axes\models.py:55
|
|
||||||
msgid "Logout Time"
|
|
||||||
msgstr "Waktu Logout"
|
|
||||||
|
|
||||||
#: .\axes\models.py:61
|
|
||||||
msgid "access log"
|
|
||||||
msgstr "log akses"
|
|
||||||
|
|
||||||
#: .\axes\models.py:62
|
|
||||||
msgid "access logs"
|
|
||||||
msgstr "log akses"
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
|
|
||||||
from axes.utils import reset
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Reset all access attempts and lockouts for a given IP address and username"
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument("ip", type=str)
|
|
||||||
parser.add_argument("username", type=str)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
count = reset(ip=options["ip"], username=options["username"])
|
|
||||||
|
|
||||||
if count:
|
|
||||||
self.stdout.write(f"{count} attempts removed.")
|
|
||||||
else:
|
|
||||||
self.stdout.write("No attempts found.")
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from asgiref.sync import iscoroutinefunction, markcoroutinefunction, sync_to_async
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
|
||||||
|
|
@ -31,37 +30,15 @@ class AxesMiddleware:
|
||||||
- ``AXES_PERMALOCK_MESSAGE``.
|
- ``AXES_PERMALOCK_MESSAGE``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async_capable = True
|
|
||||||
sync_capable = True
|
|
||||||
|
|
||||||
def __init__(self, get_response: Callable) -> None:
|
def __init__(self, get_response: Callable) -> None:
|
||||||
self.get_response = get_response
|
self.get_response = get_response
|
||||||
if iscoroutinefunction(self.get_response):
|
|
||||||
markcoroutinefunction(self)
|
|
||||||
|
|
||||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
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)
|
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 settings.AXES_ENABLED:
|
||||||
if getattr(request, "axes_locked_out", None):
|
if getattr(request, "axes_locked_out", None):
|
||||||
credentials = getattr(request, "axes_credentials", None)
|
credentials = getattr(request, "axes_credentials", None)
|
||||||
response = await sync_to_async(
|
response = get_lockout_response(request, credentials) # type: ignore
|
||||||
get_lockout_response, thread_sensitive=True
|
|
||||||
)(
|
|
||||||
request, credentials
|
|
||||||
) # type: ignore
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,12 @@
|
||||||
# Generated by Django 3.2.7 on 2021-09-13 15:16
|
# 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
|
from django.db.models import Count
|
||||||
|
|
||||||
|
|
||||||
def deduplicate_attempts(apps, schema_editor):
|
def deduplicate_attempts(apps, schema_editor):
|
||||||
AccessAttempt = apps.get_model("axes", "AccessAttempt")
|
AccessAttempt = apps.get_model("axes", "AccessAttempt")
|
||||||
db_alias = schema_editor.connection.alias
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
if db_alias != router.db_for_write(AccessAttempt):
|
|
||||||
return
|
|
||||||
|
|
||||||
duplicated_attempts = (
|
duplicated_attempts = (
|
||||||
AccessAttempt.objects.using(db_alias)
|
AccessAttempt.objects.using(db_alias)
|
||||||
.values("username", "user_agent", "ip_address")
|
.values("username", "user_agent", "ip_address")
|
||||||
|
|
@ -35,9 +31,7 @@ class Migration(migrations.Migration):
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RunPython(
|
migrations.RunPython(deduplicate_attempts),
|
||||||
deduplicate_attempts, reverse_code=migrations.RunPython.noop
|
|
||||||
),
|
|
||||||
migrations.AlterUniqueTogether(
|
migrations.AlterUniqueTogether(
|
||||||
name="accessattempt",
|
name="accessattempt",
|
||||||
unique_together={("username", "ip_address", "user_agent")},
|
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"]]
|
unique_together = [["username", "ip_address", "user_agent"]]
|
||||||
|
|
||||||
|
|
||||||
class AccessAttemptExpiration(models.Model):
|
|
||||||
access_attempt = models.OneToOneField(
|
|
||||||
AccessAttempt,
|
|
||||||
primary_key=True,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="expiration",
|
|
||||||
verbose_name=_("Access Attempt"),
|
|
||||||
)
|
|
||||||
expires_at = models.DateTimeField(
|
|
||||||
_("Expires At"),
|
|
||||||
help_text=_("The time when access attempt expires and is no longer valid."),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("access attempt expiration")
|
|
||||||
verbose_name_plural = _("access attempt expirations")
|
|
||||||
|
|
||||||
|
|
||||||
class AccessLog(AccessBase):
|
class AccessLog(AccessBase):
|
||||||
logout_time = models.DateTimeField(_("Logout Time"), null=True, blank=True)
|
logout_time = models.DateTimeField(_("Logout Time"), null=True, blank=True)
|
||||||
session_hash = models.CharField(
|
|
||||||
_("Session key hash (sha256)"), default="", blank=True, max_length=64
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Access Log for {self.username} @ {self.attempt_time}"
|
return f"Access Log for {self.username} @ {self.attempt_time}"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Requirements
|
Requirements
|
||||||
============
|
============
|
||||||
|
|
||||||
Axes requires a supported Django version and runs on Python versions 3.9 and above.
|
Axes requires a supported Django version and runs on Python versions 3.8 and above.
|
||||||
|
|
||||||
Refer to the project source code repository in
|
Refer to the project source code repository in
|
||||||
`GitHub <https://github.com/jazzband/django-axes/>`_ and see the
|
`GitHub <https://github.com/jazzband/django-axes/>`_ and see the
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,10 @@ After installing the package, the project settings need to be configured.
|
||||||
'django.contrib.auth.backends.ModelBackend',
|
'django.contrib.auth.backends.ModelBackend',
|
||||||
]
|
]
|
||||||
|
|
||||||
For backwards compatibility, ``AxesBackend`` can be used in place of ``AxesStandaloneBackend``.
|
For backwards compatibility, ``AxesBackend`` can be used in place of ``AxesStandaloneBackend``.
|
||||||
The only difference is that ``AxesBackend`` also provides the permissions-checking functionality
|
The only difference is that ``AxesBackend`` also provides the permissions-checking functionality
|
||||||
of Django's ``ModelBackend`` behind the scenes. We recommend using ``AxesStandaloneBackend``
|
of Django's ``ModelBackend`` behind the scenes. We recommend using ``AxesStandaloneBackend``
|
||||||
if you have any custom logic to override Django's standard permissions checks.
|
if you have any custom logic to override Django's standard permissions checks.
|
||||||
|
|
||||||
**3.** Add ``axes.middleware.AxesMiddleware`` to your list of ``MIDDLEWARE``::
|
**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.
|
# on failed user authentication attempts from login views.
|
||||||
# If you do not want Axes to override the authentication response
|
# If you do not want Axes to override the authentication response
|
||||||
# you can skip installing the middleware and use your own views.
|
# you can skip installing the middleware and use your own views.
|
||||||
# AxesMiddleware runs during the reponse phase. It does not conflict
|
|
||||||
# with middleware that runs in the request phase like
|
|
||||||
# django.middleware.cache.FetchFromCacheMiddleware.
|
|
||||||
'axes.middleware.AxesMiddleware',
|
'axes.middleware.AxesMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -78,20 +75,6 @@ Many people have different configurations for their development and production e
|
||||||
and running the application with misconfigured settings can prevent security features from working.
|
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
|
Version 6 breaking changes and upgrading from django-axes version 5
|
||||||
-------------------------------------------------------------------
|
-------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -137,7 +120,7 @@ To keep old behaviour with the new flag, configure the following:
|
||||||
#. 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_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_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.
|
#. 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`` 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_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_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"]]``
|
#. ``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"]]``
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ Resetting attempts from command line
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
Axes offers a command line interface with
|
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:
|
management commands with the Django ``manage.py`` or ``django-admin`` command helpers:
|
||||||
|
|
||||||
- ``python manage.py axes_reset``
|
- ``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.
|
will clear lockouts and records for the given IP addresses.
|
||||||
- ``python manage.py axes_reset_username [username ...]``
|
- ``python manage.py axes_reset_username [username ...]``
|
||||||
will clear lockouts and records for the given usernames.
|
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)``
|
- ``python manage.py axes_reset_logs (age)``
|
||||||
will reset (i.e. delete) AccessLog records that are older
|
will reset (i.e. delete) AccessLog records that are older
|
||||||
than the given age where the default is 30 days.
|
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``
|
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.
|
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.
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,11 @@ The following ``settings.py`` options are available for customizing Axes behavio
|
||||||
+======================================================+==============================================+===========================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================+
|
+======================================================+==============================================+===========================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================+
|
||||||
| AXES_ENABLED | True | Enable or disable Axes plugin functionality, for example in test runner setup |
|
| AXES_ENABLED | True | Enable or disable Axes plugin functionality, for example in test runner setup |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_FAILURE_LIMIT | 3 | The integer number of login attempts allowed before the request is considered locked. This can also be a callable or a dotted path to callable that returns an integer and all of the following are valid: ``AXES_FAILURE_LIMIT = 42``, ``AXES_FAILURE_LIMIT = lambda *args: 42``, and ``AXES_FAILURE_LIMIT = 'project.app.get_login_failure_limit'``. |
|
| AXES_FAILURE_LIMIT | 3 | The integer number of login attempts allowed before a record is created for the failed logins. This can also be a callable or a dotted path to callable that returns an integer and all of the following are valid: ``AXES_FAILURE_LIMIT = 42``, ``AXES_FAILURE_LIMIT = lambda *args: 42``, and ``AXES_FAILURE_LIMIT = 'project.app.get_login_failure_limit'``. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_LOCK_OUT_AT_FAILURE | True | After the number of allowed login attempts are exceeded, should we lock out this IP (and optional user agent)? |
|
| AXES_LOCK_OUT_AT_FAILURE | True | After the number of allowed login attempts are exceeded, should we lock out this IP (and optional user agent)? |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_COOLOFF_TIME | None | If set, defines the cool-off period after which old failed login attempts are cleared. If ``None``, lockout is permanent until attempts are manually reset. Can be set to a Python timedelta object, an integer, a float, a callable, or a string path to a callable that takes the request as argument. If an integer or float, this is interpreted as hours (``1`` is 1 hour, ``0.5`` is 30 minutes, ``1.7`` is 6120 seconds). ``timedelta`` is recommended for clarity. See also ``AXES_USE_ATTEMPT_EXPIRATION`` for rolling-window behavior. |
|
| AXES_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_USE_ATTEMPT_EXPIRATION | False | If ``True``, changes ``AXES_COOLOFF_TIME`` to a rolling window where each failed attempt expires individually after the cool-off time. This enables policies like "3 failed login attempts per 15 minutes". If ``False``, ``AXES_COOLOFF_TIME`` acts as an inactivity period where attempts are cleared only after no new failures occur within the cool-off limit. |
|
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_ONLY_ADMIN_SITE | False | If ``True``, lock is only enabled for admin site. Admin site is determined by checking request path against the path of ``"admin:index"`` view. If admin urls are not registered in current urlconf, all requests will not be locked. |
|
| AXES_ONLY_ADMIN_SITE | False | If ``True``, lock is only enabled for admin site. Admin site is determined by checking request path against the path of ``"admin:index"`` view. If admin urls are not registered in current urlconf, all requests will not be locked. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
|
|
@ -49,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_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_USERNAME_CALLABLE | None | A callable or a string path to callable that takes two arguments for user lookups: ``def get_username(request: HttpRequest, credentials: dict) -> str: ...``. This can be any callable such as ``AXES_USERNAME_CALLABLE = lambda request, credentials: 'username'`` or a full Python module path to callable such as ``AXES_USERNAME_CALLABLE = 'example.get_username``. The ``request`` is a HttpRequest like object and the ``credentials`` is a dictionary like object. ``credentials`` are the ones that were passed to Django ``authenticate()`` in the login flow. If no function is supplied, Axes fetches the username from the ``credentials`` or ``request.POST`` dictionaries based on ``AXES_USERNAME_FORM_FIELD``. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_WHITELIST_CALLABLE | None | A callable or a string path to callable that takes two arguments for whitelisting determination and returns True, if user should be whitelisted: ``def is_whitelisted(request: HttpRequest, credentials: dict) -> bool: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. |
|
| AXES_WHITELIST_CALLABLE | None | A callable or a string path to callable that takes two arguments for whitelisting determination and returns True, if user should be whitelisted: ``def is_whitelisted(request: HttpRequest, credentials: dict) -> bool: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_LOCKOUT_CALLABLE | None | A callable or a string path to callable that takes 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_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. |
|
| AXES_NEVER_LOCKOUT_GET | False | If ``True``, Axes will never lock out HTTP GET requests. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
|
|
@ -83,28 +81,11 @@ The following ``settings.py`` options are available for customizing Axes behavio
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_HTTP_RESPONSE_CODE | 429 | Sets the http response code returned when ``AXES_FAILURE_LIMIT`` is reached. For example: ``AXES_HTTP_RESPONSE_CODE = 403`` |
|
| AXES_HTTP_RESPONSE_CODE | 429 | Sets the http response code returned when ``AXES_FAILURE_LIMIT`` is reached. For example: ``AXES_HTTP_RESPONSE_CODE = 403`` |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT | True | If ``True``, any failed login attempt during lockout resets the cool-off timer to ``now() + AXES_COOLOFF_TIME``. Repeated failed attempts keep extending the lockout period. |
|
| AXES_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. |
|
| AXES_LOCKOUT_PARAMETERS | ["ip_address"] | A list of parameters that Axes uses to lock out users. It can also be callable, which takes an http request or AccesAttempt object and credentials and returns a list of parameters. Each parameter can be a string (a single parameter) or a list of strings (a combined parameter). For example, if you configure ``AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]``, axes will block clients by ip and/or username and user agent combination. See :ref:`customizing-lockout-parameters` for more details. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
|
|
||||||
**Common configurations**
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
# Classic: 3 failures -> 30 min lockout
|
|
||||||
AXES_FAILURE_LIMIT = 3
|
|
||||||
AXES_COOLOFF_TIME = timedelta(minutes=30)
|
|
||||||
|
|
||||||
# Rolling window: max 5 failures in any 15-minute period
|
|
||||||
AXES_FAILURE_LIMIT = 5
|
|
||||||
AXES_COOLOFF_TIME = timedelta(minutes=15)
|
|
||||||
AXES_USE_ATTEMPT_EXPIRATION = True
|
|
||||||
|
|
||||||
# Hard lockout (manual reset only)
|
|
||||||
AXES_FAILURE_LIMIT = 5
|
|
||||||
AXES_COOLOFF_TIME = None
|
|
||||||
|
|
||||||
The configuration option precedences for the access attempt monitoring are:
|
The configuration option precedences for the access attempt monitoring are:
|
||||||
|
|
||||||
1. Default: only use IP address.
|
1. Default: only use IP address.
|
||||||
|
|
@ -128,8 +109,6 @@ following settings to suit your set up to correctly resolve client IP addresses:
|
||||||
* ``AXES_IPWARE_META_PRECEDENCE_ORDER``: The names of ``request.META`` attributes as a tuple of strings
|
* ``AXES_IPWARE_META_PRECEDENCE_ORDER``: The names of ``request.META`` attributes as a tuple of strings
|
||||||
to check to get the client IP address. Check the Django documentation for header naming conventions.
|
to check to get the client IP address. Check the Django documentation for header naming conventions.
|
||||||
Default: ``IPWARE_META_PRECEDENCE_ORDER`` setting if set, else ``('REMOTE_ADDR', )``
|
Default: ``IPWARE_META_PRECEDENCE_ORDER`` setting if set, else ``('REMOTE_ADDR', )``
|
||||||
* ``AXES_IPWARE_PROXY_ORDER``: The order in which to evaluate IP addresses from proxy headers when multiple IPs are present
|
|
||||||
in the header chain. Must be either ``"left-most"`` or ``"right-most"``. **Default:** ``"left-most"``
|
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
For reverse proxies or e.g. Heroku, you might also want to fetch IP addresses from a HTTP header such as ``X-Forwarded-For``. To configure this, you can fetch IPs through the ``HTTP_X_FORWARDED_FOR`` key from the ``request.META`` property which contains all the HTTP headers in Django:
|
For reverse proxies or e.g. Heroku, you might also want to fetch IP addresses from a HTTP header such as ``X-Forwarded-For``. To configure this, you can fetch IPs through the ``HTTP_X_FORWARDED_FOR`` key from the ``request.META`` property which contains all the HTTP headers in Django:
|
||||||
|
|
@ -158,12 +137,6 @@ with the ``AXES_HANDLER`` setting in project configuration:
|
||||||
logs attempts to database and creates AccessAttempt and AccessLog records
|
logs attempts to database and creates AccessAttempt and AccessLog records
|
||||||
that persist until removed from the database manually or automatically
|
that persist until removed from the database manually or automatically
|
||||||
after their cool offs expire (checked on each login event).
|
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``
|
- ``axes.handlers.cache.AxesCacheHandler``
|
||||||
only uses the cache for monitoring attempts and does not persist data
|
only uses the cache for monitoring attempts and does not persist data
|
||||||
other than in the cache backend; this data can be purged automatically
|
other than in the cache backend; this data can be purged automatically
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ An example of usage could be e.g. a custom view for processing lockouts.
|
||||||
|
|
||||||
from django.http import JsonResponse
|
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)
|
return JsonResponse({"status": "Locked out due to too many login failures"}, status=403)
|
||||||
|
|
||||||
``settings.py``::
|
``settings.py``::
|
||||||
|
|
@ -188,7 +188,7 @@ Example ``AXES_LOCKOUT_PARAMETERS`` configuration:
|
||||||
|
|
||||||
AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]
|
AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]
|
||||||
|
|
||||||
This way, axes will lock out users using ip_address or combination of username and user_agent
|
This way, axes will lock out users using ip_address and/or combination of username and user agent
|
||||||
|
|
||||||
Example of callable ``AXES_LOCKOUT_PARAMETERS``:
|
Example of callable ``AXES_LOCKOUT_PARAMETERS``:
|
||||||
|
|
||||||
|
|
@ -213,7 +213,7 @@ Example of callable ``AXES_LOCKOUT_PARAMETERS``:
|
||||||
|
|
||||||
AXES_LOCKOUT_PARAMETERS = "example.utils.get_lockout_parameters"
|
AXES_LOCKOUT_PARAMETERS = "example.utils.get_lockout_parameters"
|
||||||
|
|
||||||
This way, if client ip_address is localhost, axes will lockout client only by username. In other case, axes will lockout client by username or ip_address.
|
This way, if client ip_address is localhost, axes will lockout client only by username. In other case, axes will lockout client by username and/or ip_address.
|
||||||
|
|
||||||
Customizing client ip address lookups
|
Customizing client ip address lookups
|
||||||
-------------------------------------
|
-------------------------------------
|
||||||
|
|
|
||||||
9
docs/_static/css/custom_theme.css
vendored
9
docs/_static/css/custom_theme.css
vendored
|
|
@ -1,9 +0,0 @@
|
||||||
@import url("theme.css");
|
|
||||||
|
|
||||||
.wy-nav-content {
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wy-table-responsive table td, .wy-table-responsive table th {
|
|
||||||
white-space: inherit;
|
|
||||||
}
|
|
||||||
12
docs/conf.py
12
docs/conf.py
|
|
@ -6,8 +6,8 @@ More information on the configuration options is available at:
|
||||||
https://www.sphinx-doc.org/en/master/usage/configuration.html
|
https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# import sphinx_rtd_theme
|
import sphinx_rtd_theme
|
||||||
from importlib.metadata import version as get_version
|
from pkg_resources import get_distribution
|
||||||
|
|
||||||
import django
|
import django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
@ -25,7 +25,7 @@ description = ("Keep track of failed login attempts in Django-powered sites.",)
|
||||||
|
|
||||||
# Add any Sphinx extension module names here, as strings.
|
# Add any Sphinx extension module names here, as strings.
|
||||||
# They can be extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
# They can be extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
||||||
extensions = ["sphinx_rtd_theme","sphinx.ext.autodoc"]
|
extensions = ["sphinx.ext.autodoc"]
|
||||||
|
|
||||||
# Add any paths that contain templates here, relative to this directory.
|
# Add any paths that contain templates here, relative to this directory.
|
||||||
templates_path = ["_templates"]
|
templates_path = ["_templates"]
|
||||||
|
|
@ -43,7 +43,7 @@ copyright = "2016, Jazzband"
|
||||||
author = "Jazzband"
|
author = "Jazzband"
|
||||||
|
|
||||||
# The full version, including alpha/beta/rc tags.
|
# The full version, including alpha/beta/rc tags.
|
||||||
release = get_version("django-axes")
|
release = get_distribution("django-axes").version
|
||||||
|
|
||||||
# The short X.Y version.
|
# The short X.Y version.
|
||||||
version = ".".join(release.split(".")[:2])
|
version = ".".join(release.split(".")[:2])
|
||||||
|
|
@ -71,10 +71,8 @@ todo_include_todos = False
|
||||||
# a list of builtin themes.
|
# a list of builtin themes.
|
||||||
html_theme = "sphinx_rtd_theme"
|
html_theme = "sphinx_rtd_theme"
|
||||||
|
|
||||||
html_style = "css/custom_theme.css"
|
|
||||||
|
|
||||||
# Add any paths that contain custom themes here, relative to this directory.
|
# Add any paths that contain custom themes here, relative to this directory.
|
||||||
# html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||||
|
|
||||||
# Add any paths that contain custom static files (such as style sheets) here,
|
# Add any paths that contain custom static files (such as style sheets) here,
|
||||||
# relative to this directory. They are copied after the builtin static files,
|
# relative to this directory. They are copied after the builtin static files,
|
||||||
|
|
|
||||||
2
mypy.ini
2
mypy.ini
|
|
@ -1,5 +1,5 @@
|
||||||
[mypy]
|
[mypy]
|
||||||
python_version = 3.14
|
python_version = 3.8
|
||||||
ignore_missing_imports = True
|
ignore_missing_imports = True
|
||||||
|
|
||||||
[mypy-axes.migrations.*]
|
[mypy-axes.migrations.*]
|
||||||
|
|
|
||||||
|
|
@ -10,35 +10,35 @@ DJANGO_SETTINGS_MODULE = "tests.settings"
|
||||||
legacy_tox_ini = """
|
legacy_tox_ini = """
|
||||||
[tox]
|
[tox]
|
||||||
envlist =
|
envlist =
|
||||||
py{310,311,312}-dj42
|
py{38,39,310,py38}-dj32
|
||||||
py{310,311,312,313}-dj52
|
py{38,39,310,311,py38}-dj41
|
||||||
py{312,313,314}-dj60
|
py{38,39,310,311,py38}-dj42
|
||||||
py314-djmain
|
py311-djmain
|
||||||
py314-djqa
|
py311-djqa
|
||||||
|
|
||||||
[gh-actions]
|
[gh-actions]
|
||||||
python =
|
python =
|
||||||
|
3.8: py38
|
||||||
|
3.9: py39
|
||||||
3.10: py310
|
3.10: py310
|
||||||
3.11: py311
|
3.11: py311
|
||||||
3.12: py312
|
pypy-3.8: pypy38
|
||||||
3.13: py313
|
|
||||||
3.14: py314
|
|
||||||
|
|
||||||
[gh-actions:env]
|
[gh-actions:env]
|
||||||
DJANGO =
|
DJANGO =
|
||||||
|
3.2: dj32
|
||||||
|
4.1: dj41
|
||||||
4.2: dj42
|
4.2: dj42
|
||||||
5.2: dj52
|
|
||||||
6.0: dj60
|
|
||||||
main: djmain
|
main: djmain
|
||||||
qa: djqa
|
qa: djqa
|
||||||
|
|
||||||
# Normal test environment runs pytest which orchestrates other tools
|
# Normal test environment runs pytest which orchestrates other tools
|
||||||
[testenv]
|
[testenv]
|
||||||
deps =
|
deps =
|
||||||
-r requirements.txt
|
-r requirements-test.txt
|
||||||
dj42: django>=4.2,<4.3
|
dj32: django>=3.2,<3.3
|
||||||
dj52: django>=5.2,<5.3
|
dj41: django>=4.1,<4.2
|
||||||
dj60: django>=6.0,<6.1
|
dj42: django>=4.1,<4.2
|
||||||
djmain: https://github.com/django/django/archive/main.tar.gz
|
djmain: https://github.com/django/django/archive/main.tar.gz
|
||||||
usedevelop = true
|
usedevelop = true
|
||||||
commands = pytest
|
commands = pytest
|
||||||
|
|
@ -47,15 +47,16 @@ setenv =
|
||||||
# Django development version is allowed to fail the test matrix
|
# Django development version is allowed to fail the test matrix
|
||||||
ignore_outcome =
|
ignore_outcome =
|
||||||
djmain: True
|
djmain: True
|
||||||
|
pypy38: True
|
||||||
ignore_errors =
|
ignore_errors =
|
||||||
djmain: True
|
djmain: True
|
||||||
|
pypy38: True
|
||||||
|
|
||||||
# QA runs type checks, linting, and code formatting checks
|
# QA runs type checks, linting, and code formatting checks
|
||||||
[testenv:py314-djqa]
|
[testenv:py311-djqa]
|
||||||
stoponfail = false
|
deps = -r requirements-qa.txt
|
||||||
deps = -r requirements.txt
|
|
||||||
commands =
|
commands =
|
||||||
mypy axes
|
mypy axes
|
||||||
prospector axes
|
prospector
|
||||||
black --check --diff axes
|
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.3.0
|
||||||
|
prospector==1.10.0
|
||||||
|
types-pkg_resources # Type stub
|
||||||
7
requirements-test.txt
Normal file
7
requirements-test.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
-e .
|
||||||
|
django-ipware>=3
|
||||||
|
coverage==7.2.5
|
||||||
|
pytest==7.3.1
|
||||||
|
pytest-cov==4.0.0
|
||||||
|
pytest-django==4.5.2
|
||||||
|
pytest-subtests==0.11.0
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
-e .
|
-e .
|
||||||
black==26.3.1
|
-r requirements-qa.txt
|
||||||
coverage==7.13.5
|
-r requirements-test.txt
|
||||||
django-ipware>=3
|
sphinx_rtd_theme==1.2.0
|
||||||
mypy==1.19.1
|
tox==4.5.1
|
||||||
prospector==1.18.0
|
|
||||||
pytest-cov==7.0.0
|
|
||||||
pytest-django==4.12.0
|
|
||||||
pytest-subtests==0.15.0
|
|
||||||
pytest==9.0.2
|
|
||||||
sphinx_rtd_theme==3.1.0
|
|
||||||
tox==4.50.1
|
|
||||||
|
|
|
||||||
17
setup.py
17
setup.py
|
|
@ -35,11 +35,8 @@ setup(
|
||||||
package_dir={"axes": "axes"},
|
package_dir={"axes": "axes"},
|
||||||
use_scm_version=True,
|
use_scm_version=True,
|
||||||
setup_requires=["setuptools_scm"],
|
setup_requires=["setuptools_scm"],
|
||||||
python_requires=">=3.10",
|
python_requires=">=3.7",
|
||||||
install_requires=[
|
install_requires=["django>=3.2", "setuptools"],
|
||||||
"django>=4.2",
|
|
||||||
"asgiref>=3.6.0",
|
|
||||||
],
|
|
||||||
extras_require={
|
extras_require={
|
||||||
"ipware": "django-ipware>=3",
|
"ipware": "django-ipware>=3",
|
||||||
},
|
},
|
||||||
|
|
@ -50,21 +47,21 @@ setup(
|
||||||
"Environment :: Web Environment",
|
"Environment :: Web Environment",
|
||||||
"Environment :: Plugins",
|
"Environment :: Plugins",
|
||||||
"Framework :: Django",
|
"Framework :: Django",
|
||||||
|
"Framework :: Django :: 3.2",
|
||||||
|
"Framework :: Django :: 4.1",
|
||||||
"Framework :: Django :: 4.2",
|
"Framework :: Django :: 4.2",
|
||||||
"Framework :: Django :: 5.2",
|
|
||||||
"Framework :: Django :: 6.0",
|
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"Intended Audience :: System Administrators",
|
"Intended Audience :: System Administrators",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
"Programming Language :: Python",
|
"Programming Language :: Python",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.8",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
|
||||||
"Programming Language :: Python :: 3.13",
|
|
||||||
"Programming Language :: Python :: 3.14",
|
|
||||||
"Programming Language :: Python :: Implementation :: CPython",
|
"Programming Language :: Python :: Implementation :: CPython",
|
||||||
|
"Programming Language :: Python :: Implementation :: PyPy",
|
||||||
"Topic :: Internet :: Log Analysis",
|
"Topic :: Internet :: Log Analysis",
|
||||||
"Topic :: Security",
|
"Topic :: Security",
|
||||||
"Topic :: System :: Logging",
|
"Topic :: System :: Logging",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ from string import ascii_letters, digits
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.base_user import AbstractBaseUser
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.http import HttpRequest
|
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 django.utils.timezone import now
|
||||||
|
|
||||||
from axes.attempts import get_cool_off_threshold
|
from axes.attempts import get_cool_off_threshold
|
||||||
|
|
@ -15,13 +15,12 @@ class GetCoolOffThresholdTestCase(AxesTestCase):
|
||||||
def test_get_cool_off_threshold(self):
|
def test_get_cool_off_threshold(self):
|
||||||
timestamp = now()
|
timestamp = now()
|
||||||
|
|
||||||
request = RequestFactory().post("/")
|
|
||||||
with patch("axes.attempts.now", return_value=timestamp):
|
with patch("axes.attempts.now", return_value=timestamp):
|
||||||
request.axes_attempt_time = timestamp
|
attempt_time = timestamp
|
||||||
threshold_now = get_cool_off_threshold(request)
|
threshold_now = get_cool_off_threshold(attempt_time)
|
||||||
|
|
||||||
request.axes_attempt_time = None
|
attempt_time = None
|
||||||
threshold_none = get_cool_off_threshold(request)
|
threshold_none = get_cool_off_threshold(attempt_time)
|
||||||
|
|
||||||
self.assertEqual(threshold_now, threshold_none)
|
self.assertEqual(threshold_now, threshold_none)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,43 +110,3 @@ class DeprecatedSettingsTestCase(AxesTestCase):
|
||||||
def test_deprecated_success_access_log_flag(self):
|
def test_deprecated_success_access_log_flag(self):
|
||||||
warnings = run_checks()
|
warnings = run_checks()
|
||||||
self.assertEqual(warnings, [self.disable_success_access_log_warning])
|
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 platform import python_implementation
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
from datetime import datetime, timezone as dt_timezone
|
|
||||||
from django.test import override_settings
|
|
||||||
from django.utils import timezone
|
|
||||||
from axes.handlers.database import AxesDatabaseHandler
|
|
||||||
from axes.models import AccessAttempt, AccessLog, AccessFailureLog, AccessAttemptExpiration
|
|
||||||
|
|
||||||
from pytest import mark
|
from pytest import mark
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.test import override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.timezone import timedelta
|
from django.utils.timezone import timedelta
|
||||||
|
|
||||||
from axes.conf import settings
|
from axes.conf import settings
|
||||||
from axes.handlers.proxy import AxesProxyHandler
|
from axes.handlers.proxy import AxesProxyHandler
|
||||||
from axes.helpers import get_client_str
|
from axes.helpers import get_client_str
|
||||||
|
from axes.models import AccessAttempt, AccessLog, AccessFailureLog
|
||||||
from tests.base import AxesTestCase
|
from tests.base import AxesTestCase
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -238,6 +236,11 @@ class ResetAttemptsTestCase(AxesHandlerBaseTestCase):
|
||||||
AXES_RESET_ON_SUCCESS=True,
|
AXES_RESET_ON_SUCCESS=True,
|
||||||
AXES_ENABLE_ACCESS_FAILURE_LOG=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):
|
class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase):
|
||||||
def test_handler_reset_attempts(self):
|
def test_handler_reset_attempts(self):
|
||||||
self.create_attempt()
|
self.create_attempt()
|
||||||
|
|
@ -569,170 +572,3 @@ class AxesTestHandlerTestCase(AxesHandlerBaseTestCase):
|
||||||
|
|
||||||
def test_handler_get_failures(self):
|
def test_handler_get_failures(self):
|
||||||
self.assertEqual(0, AxesProxyHandler.get_failures(self.request, {}))
|
self.assertEqual(0, AxesProxyHandler.get_failures(self.request, {}))
|
||||||
|
|
||||||
|
|
||||||
@override_settings(AXES_HANDLER="axes.handlers.database.AxesDatabaseHandler", AXES_COOLOFF_TIME=timezone.timedelta(seconds=10))
|
|
||||||
class AxesDatabaseHandlerExpirationFlagTestCase(AxesTestCase):
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp()
|
|
||||||
self.handler = AxesDatabaseHandler()
|
|
||||||
self.mock_request = MagicMock()
|
|
||||||
self.mock_credentials = None
|
|
||||||
|
|
||||||
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
|
|
||||||
@patch("axes.handlers.database.log")
|
|
||||||
@patch("axes.models.AccessAttempt.objects.filter")
|
|
||||||
@patch("django.utils.timezone.now")
|
|
||||||
def test_clean_expired_user_attempts_expiration_true(self, mock_now, mock_filter, mock_log):
|
|
||||||
mock_now.return_value = datetime(2025, 1, 1, tzinfo=dt_timezone.utc)
|
|
||||||
mock_qs = MagicMock()
|
|
||||||
mock_filter.return_value = mock_qs
|
|
||||||
mock_qs.delete.return_value = (3, None)
|
|
||||||
|
|
||||||
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
|
||||||
mock_filter.assert_called_once_with(expiration__expires_at__lte=mock_now.return_value)
|
|
||||||
mock_qs.delete.assert_called_once()
|
|
||||||
mock_log.info.assert_called_with(
|
|
||||||
"AXES: Cleaned up %s expired access attempts from database that expiry were older than %s",
|
|
||||||
3,
|
|
||||||
mock_now.return_value,
|
|
||||||
)
|
|
||||||
self.assertEqual(count, 3)
|
|
||||||
|
|
||||||
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
|
|
||||||
@patch("axes.handlers.database.log")
|
|
||||||
def test_clean_expired_user_attempts_expiration_true_with_complete_deletion(self, mock_log):
|
|
||||||
AccessAttempt.objects.all().delete()
|
|
||||||
dummy_attempt = AccessAttempt.objects.create(
|
|
||||||
username="test_user",
|
|
||||||
ip_address="192.168.1.1",
|
|
||||||
failures_since_start=1,
|
|
||||||
user_agent="test_agent",
|
|
||||||
)
|
|
||||||
dummy_attempt.expiration = AccessAttemptExpiration.objects.create(
|
|
||||||
access_attempt=dummy_attempt,
|
|
||||||
expires_at=timezone.now() - timezone.timedelta(days=1) # Set to expire in the past
|
|
||||||
)
|
|
||||||
|
|
||||||
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
|
||||||
mock_log.info.assert_called_once()
|
|
||||||
|
|
||||||
# comparing count=2, as one is the dummy attempt and one is the expiration
|
|
||||||
self.assertEqual(count, 2)
|
|
||||||
self.assertEqual(
|
|
||||||
AccessAttempt.objects.count(), 0
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
AccessAttemptExpiration.objects.count(), 0
|
|
||||||
)
|
|
||||||
|
|
||||||
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
|
|
||||||
@patch("axes.handlers.database.log")
|
|
||||||
def test_clean_expired_user_attempts_expiration_true_with_partial_deletion(self, mock_log):
|
|
||||||
|
|
||||||
attempt_not_expired = AccessAttempt.objects.create(
|
|
||||||
username="test_user",
|
|
||||||
ip_address="192.168.1.1",
|
|
||||||
failures_since_start=1,
|
|
||||||
user_agent="test_agent",
|
|
||||||
)
|
|
||||||
attempt_not_expired.expiration = AccessAttemptExpiration.objects.create(
|
|
||||||
access_attempt=attempt_not_expired,
|
|
||||||
expires_at=timezone.now() + timezone.timedelta(days=1) # Set to expire in the future
|
|
||||||
)
|
|
||||||
|
|
||||||
attempt_expired = AccessAttempt.objects.create(
|
|
||||||
username="test_user_2",
|
|
||||||
ip_address="192.168.1.2",
|
|
||||||
failures_since_start=1,
|
|
||||||
user_agent="test_agent",
|
|
||||||
)
|
|
||||||
attempt_expired.expiration = AccessAttemptExpiration.objects.create(
|
|
||||||
access_attempt=attempt_expired,
|
|
||||||
expires_at=timezone.now() - timezone.timedelta(days=1) # Set to expire in the past
|
|
||||||
)
|
|
||||||
|
|
||||||
access_attempt_count = AccessAttempt.objects.count()
|
|
||||||
access_attempt_expiration_count = AccessAttemptExpiration.objects.count()
|
|
||||||
|
|
||||||
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
|
||||||
mock_log.info.assert_called_once()
|
|
||||||
|
|
||||||
# comparing count=2, as one is the dummy attempt and one is the expiration
|
|
||||||
self.assertEqual(count, 2)
|
|
||||||
self.assertEqual(
|
|
||||||
AccessAttempt.objects.count(), access_attempt_count - 1
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
AccessAttemptExpiration.objects.count(), access_attempt_expiration_count - 1
|
|
||||||
)
|
|
||||||
|
|
||||||
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
|
|
||||||
@patch("axes.handlers.database.log")
|
|
||||||
def test_clean_expired_user_attempts_expiration_true_with_no_deletion(self, mock_log):
|
|
||||||
|
|
||||||
attempt_not_expired_1 = AccessAttempt.objects.create(
|
|
||||||
username="test_user",
|
|
||||||
ip_address="192.168.1.1",
|
|
||||||
failures_since_start=1,
|
|
||||||
user_agent="test_agent",
|
|
||||||
)
|
|
||||||
attempt_not_expired_1.expiration = AccessAttemptExpiration.objects.create(
|
|
||||||
access_attempt=attempt_not_expired_1,
|
|
||||||
expires_at=timezone.now() + timezone.timedelta(days=1) # Set to expire in the future
|
|
||||||
)
|
|
||||||
|
|
||||||
attempt_not_expired_2 = AccessAttempt.objects.create(
|
|
||||||
username="test_user_2",
|
|
||||||
ip_address="192.168.1.2",
|
|
||||||
failures_since_start=1,
|
|
||||||
user_agent="test_agent",
|
|
||||||
)
|
|
||||||
attempt_not_expired_2.expiration = AccessAttemptExpiration.objects.create(
|
|
||||||
access_attempt=attempt_not_expired_2,
|
|
||||||
expires_at=timezone.now() + timezone.timedelta(days=2) # Set to expire in the future
|
|
||||||
)
|
|
||||||
|
|
||||||
access_attempt_count = AccessAttempt.objects.count()
|
|
||||||
access_attempt_expiration_count = AccessAttemptExpiration.objects.count()
|
|
||||||
|
|
||||||
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
|
||||||
mock_log.info.assert_called_once()
|
|
||||||
|
|
||||||
# comparing count=2, as one is the dummy attempt and one is the expiration
|
|
||||||
self.assertEqual(count, 0)
|
|
||||||
self.assertEqual(
|
|
||||||
AccessAttempt.objects.count(), access_attempt_count
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
AccessAttemptExpiration.objects.count(), access_attempt_expiration_count
|
|
||||||
)
|
|
||||||
|
|
||||||
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=False)
|
|
||||||
@patch("axes.handlers.database.log")
|
|
||||||
@patch("axes.handlers.database.get_cool_off_threshold")
|
|
||||||
@patch("axes.models.AccessAttempt.objects.filter")
|
|
||||||
def test_clean_expired_user_attempts_expiration_false(self, mock_filter, mock_get_threshold, mock_log):
|
|
||||||
mock_get_threshold.return_value = "fake-threshold"
|
|
||||||
mock_qs = MagicMock()
|
|
||||||
mock_filter.return_value = mock_qs
|
|
||||||
mock_qs.delete.return_value = (2, None)
|
|
||||||
|
|
||||||
count = self.handler.clean_expired_user_attempts(request=self.mock_request, credentials=None)
|
|
||||||
mock_filter.assert_called_once_with(attempt_time__lte="fake-threshold")
|
|
||||||
mock_qs.delete.assert_called_once()
|
|
||||||
mock_log.info.assert_called_with(
|
|
||||||
"AXES: Cleaned up %s expired access attempts from database that were older than %s",
|
|
||||||
2,
|
|
||||||
"fake-threshold",
|
|
||||||
)
|
|
||||||
self.assertEqual(count, 2)
|
|
||||||
|
|
||||||
@override_settings(AXES_COOLOFF_TIME=None)
|
|
||||||
@patch("axes.handlers.database.log")
|
|
||||||
def test_clean_expired_user_attempts_no_cooloff(self, mock_log):
|
|
||||||
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
|
||||||
mock_log.debug.assert_called_with(
|
|
||||||
"AXES: Skipping clean for expired access attempts because no AXES_COOLOFF_TIME is configured"
|
|
||||||
)
|
|
||||||
self.assertEqual(count, 0)
|
|
||||||
|
|
|
||||||
|
|
@ -59,38 +59,6 @@ class CacheTestCase(AxesTestCase):
|
||||||
def test_get_cache_timeout_none(self):
|
def test_get_cache_timeout_none(self):
|
||||||
self.assertEqual(get_cache_timeout(), None)
|
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):
|
class TimestampTestCase(AxesTestCase):
|
||||||
def test_iso8601(self):
|
def test_iso8601(self):
|
||||||
|
|
@ -114,7 +82,6 @@ class TimestampTestCase(AxesTestCase):
|
||||||
self.assertEqual(get_cool_off_iso8601(delta), iso_duration)
|
self.assertEqual(get_cool_off_iso8601(delta), iso_duration)
|
||||||
|
|
||||||
|
|
||||||
@override_settings(AXES_SENSITIVE_PARAMETERS=[])
|
|
||||||
class ClientStringTestCase(AxesTestCase):
|
class ClientStringTestCase(AxesTestCase):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_expected_client_str(*args, **kwargs):
|
def get_expected_client_str(*args, **kwargs):
|
||||||
|
|
@ -947,7 +914,7 @@ class LockoutResponseTestCase(AxesTestCase):
|
||||||
self.assertEqual(type(response), HttpResponse)
|
self.assertEqual(type(response), HttpResponse)
|
||||||
|
|
||||||
|
|
||||||
def mock_get_cool_off_str(req):
|
def mock_get_cool_off_str():
|
||||||
return timedelta(seconds=30)
|
return timedelta(seconds=30)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -961,18 +928,18 @@ class AxesCoolOffTestCase(AxesTestCase):
|
||||||
self.assertEqual(get_cool_off(), timedelta(hours=2))
|
self.assertEqual(get_cool_off(), timedelta(hours=2))
|
||||||
|
|
||||||
@override_settings(AXES_COOLOFF_TIME=2.0)
|
@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))
|
self.assertEqual(get_cool_off(), timedelta(minutes=120))
|
||||||
|
|
||||||
@override_settings(AXES_COOLOFF_TIME=0.25)
|
@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))
|
self.assertEqual(get_cool_off(), timedelta(minutes=15))
|
||||||
|
|
||||||
@override_settings(AXES_COOLOFF_TIME=1.7)
|
@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))
|
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):
|
def test_get_cool_off_callable(self):
|
||||||
self.assertEqual(get_cool_off(), timedelta(seconds=30))
|
self.assertEqual(get_cool_off(), timedelta(seconds=30))
|
||||||
|
|
||||||
|
|
@ -1013,16 +980,9 @@ def mock_get_lockout_response(request, credentials):
|
||||||
return HttpResponse(status=400)
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
|
||||||
def mock_get_lockout_response_with_original_response_param(
|
|
||||||
request, response, credentials
|
|
||||||
):
|
|
||||||
return HttpResponse(status=400)
|
|
||||||
|
|
||||||
|
|
||||||
class AxesLockoutTestCase(AxesTestCase):
|
class AxesLockoutTestCase(AxesTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.request = HttpRequest()
|
self.request = HttpRequest()
|
||||||
self.response = HttpResponse()
|
|
||||||
self.credentials = dict()
|
self.credentials = dict()
|
||||||
|
|
||||||
def test_get_lockout_response(self):
|
def test_get_lockout_response(self):
|
||||||
|
|
@ -1046,20 +1006,6 @@ class AxesLockoutTestCase(AxesTestCase):
|
||||||
response = get_lockout_response(self.request, self.credentials)
|
response = get_lockout_response(self.request, self.credentials)
|
||||||
self.assertEqual(400, response.status_code)
|
self.assertEqual(400, response.status_code)
|
||||||
|
|
||||||
@override_settings(
|
|
||||||
AXES_LOCKOUT_CALLABLE=mock_get_lockout_response_with_original_response_param
|
|
||||||
)
|
|
||||||
def test_get_lockout_response_override_callable_with_original_response_param(self):
|
|
||||||
response = get_lockout_response(self.request, self.response, self.credentials)
|
|
||||||
self.assertEqual(400, response.status_code)
|
|
||||||
|
|
||||||
@override_settings(
|
|
||||||
AXES_LOCKOUT_CALLABLE="tests.test_helpers.mock_get_lockout_response_with_original_response_param"
|
|
||||||
)
|
|
||||||
def test_get_lockout_response_override_path_with_original_response_param(self):
|
|
||||||
response = get_lockout_response(self.request, self.response, self.credentials)
|
|
||||||
self.assertEqual(400, response.status_code)
|
|
||||||
|
|
||||||
@override_settings(AXES_LOCKOUT_CALLABLE=42)
|
@override_settings(AXES_LOCKOUT_CALLABLE=42)
|
||||||
def test_get_lockout_response_override_invalid(self):
|
def test_get_lockout_response_override_invalid(self):
|
||||||
with self.assertRaises(TypeError):
|
with self.assertRaises(TypeError):
|
||||||
|
|
@ -1074,7 +1020,6 @@ class AxesCleanseParamsTestCase(AxesTestCase):
|
||||||
"other_sensitive_data": "sensitive",
|
"other_sensitive_data": "sensitive",
|
||||||
}
|
}
|
||||||
|
|
||||||
@override_settings(AXES_SENSITIVE_PARAMETERS=[])
|
|
||||||
def test_cleanse_parameters(self):
|
def test_cleanse_parameters(self):
|
||||||
cleansed = cleanse_parameters(self.parameters)
|
cleansed = cleanse_parameters(self.parameters)
|
||||||
self.assertEqual("test_user", cleansed["username"])
|
self.assertEqual("test_user", cleansed["username"])
|
||||||
|
|
@ -1096,7 +1041,6 @@ class AxesCleanseParamsTestCase(AxesTestCase):
|
||||||
self.assertEqual("********************", cleansed["password"])
|
self.assertEqual("********************", cleansed["password"])
|
||||||
self.assertEqual("********************", cleansed["other_sensitive_data"])
|
self.assertEqual("********************", cleansed["other_sensitive_data"])
|
||||||
|
|
||||||
@override_settings(AXES_SENSITIVE_PARAMETERS=[])
|
|
||||||
@override_settings(AXES_PASSWORD_FORM_FIELD=None)
|
@override_settings(AXES_PASSWORD_FORM_FIELD=None)
|
||||||
def test_cleanse_parameters_override_empty(self):
|
def test_cleanse_parameters_override_empty(self):
|
||||||
cleansed = cleanse_parameters(self.parameters)
|
cleansed = cleanse_parameters(self.parameters)
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.test import override_settings
|
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.apps import AppConfig
|
||||||
from axes.models import AccessAttempt, AccessLog
|
from axes.models import AccessAttempt, AccessLog
|
||||||
from tests.base import AxesTestCase
|
from tests.base import AxesTestCase
|
||||||
|
|
||||||
_BEGIN = "AXES: BEGIN version %s, %s"
|
_BEGIN = "AXES: BEGIN version %s, %s"
|
||||||
_VERSION = __version__
|
_VERSION = get_distribution("django-axes").version
|
||||||
|
|
||||||
|
|
||||||
@patch("axes.apps.AppConfig.initialized", False)
|
@patch("axes.apps.AppConfig.initialized", False)
|
||||||
|
|
@ -58,21 +59,16 @@ class AppsTestCase(AxesTestCase):
|
||||||
class AccessLogTestCase(AxesTestCase):
|
class AccessLogTestCase(AxesTestCase):
|
||||||
def test_access_log_on_logout(self):
|
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)
|
self.login(is_valid_username=True, is_valid_password=True)
|
||||||
latest_log = AccessLog.objects.latest("id")
|
self.assertIsNone(AccessLog.objects.latest("id").logout_time)
|
||||||
self.assertIsNone(latest_log.logout_time)
|
|
||||||
other_log = self.create_log(session_hash='not-the-session')
|
|
||||||
self.assertIsNone(other_log.logout_time)
|
|
||||||
|
|
||||||
response = self.logout()
|
response = self.client.post(reverse("admin:logout"))
|
||||||
self.assertContains(response, "Logged out")
|
self.assertContains(response, "Logged out")
|
||||||
other_log.refresh_from_db()
|
|
||||||
self.assertIsNone(other_log.logout_time)
|
self.assertIsNotNone(AccessLog.objects.latest("id").logout_time)
|
||||||
latest_log.refresh_from_db()
|
|
||||||
self.assertIsNotNone(latest_log.logout_time)
|
|
||||||
|
|
||||||
@override_settings(DATA_UPLOAD_MAX_NUMBER_FIELDS=1500)
|
@override_settings(DATA_UPLOAD_MAX_NUMBER_FIELDS=1500)
|
||||||
def test_log_data_truncated(self):
|
def test_log_data_truncated(self):
|
||||||
|
|
@ -81,7 +77,7 @@ class AccessLogTestCase(AxesTestCase):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# An impossibly large post dict
|
# 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.login(**extra_data)
|
||||||
self.assertEqual(len(AccessAttempt.objects.latest("id").post_data), 1024)
|
self.assertEqual(len(AccessAttempt.objects.latest("id").post_data), 1024)
|
||||||
|
|
||||||
|
|
@ -90,7 +86,7 @@ class AccessLogTestCase(AxesTestCase):
|
||||||
AccessLog.objects.all().delete()
|
AccessLog.objects.all().delete()
|
||||||
|
|
||||||
response = self.login(is_valid_username=True, is_valid_password=True)
|
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.assertEqual(AccessLog.objects.all().count(), 0)
|
||||||
self.assertContains(response, "Logged out", html=True)
|
self.assertContains(response, "Logged out", html=True)
|
||||||
|
|
@ -113,7 +109,7 @@ class AccessLogTestCase(AxesTestCase):
|
||||||
AccessLog.objects.all().delete()
|
AccessLog.objects.all().delete()
|
||||||
|
|
||||||
response = self.login(is_valid_username=True, is_valid_password=True)
|
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.assertEqual(AccessLog.objects.count(), 0)
|
||||||
self.assertContains(response, "Logged out", html=True)
|
self.assertContains(response, "Logged out", html=True)
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ class DjangoLoginTestCase(TestCase):
|
||||||
self.username = "john.doe"
|
self.username = "john.doe"
|
||||||
self.password = "hunter2"
|
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.set_password(self.password)
|
||||||
self.user.save()
|
self.user.save()
|
||||||
self.user.backend = "django.contrib.auth.backends.ModelBackend"
|
self.user.backend = "django.contrib.auth.backends.ModelBackend"
|
||||||
|
|
@ -47,19 +47,13 @@ class DjangoContribAuthLoginTestCase(DjangoLoginTestCase):
|
||||||
class DjangoTestClientLoginTestCase(DjangoLoginTestCase):
|
class DjangoTestClientLoginTestCase(DjangoLoginTestCase):
|
||||||
def test_client_login(self):
|
def test_client_login(self):
|
||||||
self.client.login(username=self.username, password=self.password)
|
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):
|
def test_client_logout(self):
|
||||||
self.client.login(username=self.username, password=self.password)
|
self.client.login(username=self.username, password=self.password)
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
response = self.client.get(reverse("admin:index"))
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
|
|
||||||
def test_client_force_login(self):
|
def test_client_force_login(self):
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
response = self.client.get(reverse("admin:index"))
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
|
|
||||||
class DatabaseLoginTestCase(AxesTestCase):
|
class DatabaseLoginTestCase(AxesTestCase):
|
||||||
|
|
@ -289,7 +283,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
||||||
# Test he is locked by user_agent:
|
# Test he is locked by user_agent:
|
||||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
|
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
|
||||||
self.assertEqual(response.status_code, self.BLOCKED)
|
self.assertEqual(response.status_code, self.BLOCKED)
|
||||||
|
|
||||||
# Test he is allowed to login with different username, ip and user_agent
|
# 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")
|
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)
|
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||||
|
|
@ -314,7 +308,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
||||||
# Test he is allowed to login with different user_agent:
|
# 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")
|
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)
|
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||||
|
|
||||||
# Test he is allowed to login with different username, ip and user_agent
|
# 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")
|
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)
|
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||||
|
|
|
||||||
|
|
@ -56,22 +56,18 @@ class ManagementCommandTestCase(AxesTestCase):
|
||||||
username="john.doe", ip_address="10.0.0.2", failures_since_start="15"
|
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):
|
def test_axes_list_attempts(self):
|
||||||
out = StringIO()
|
out = StringIO()
|
||||||
call_command("axes_list_attempts", stdout=out)
|
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())
|
self.assertEqual(expected, out.getvalue())
|
||||||
|
|
||||||
def test_axes_reset(self):
|
def test_axes_reset(self):
|
||||||
out = StringIO()
|
out = StringIO()
|
||||||
call_command("axes_reset", stdout=out)
|
call_command("axes_reset", stdout=out)
|
||||||
|
|
||||||
expected = "3 attempts removed.\n"
|
expected = "2 attempts removed.\n"
|
||||||
self.assertEqual(expected, out.getvalue())
|
self.assertEqual(expected, out.getvalue())
|
||||||
|
|
||||||
def test_axes_reset_not_found(self):
|
def test_axes_reset_not_found(self):
|
||||||
|
|
@ -91,13 +87,6 @@ class ManagementCommandTestCase(AxesTestCase):
|
||||||
expected = "1 attempts removed.\n"
|
expected = "1 attempts removed.\n"
|
||||||
self.assertEqual(expected, out.getvalue())
|
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):
|
def test_axes_reset_ip_not_found(self):
|
||||||
out = StringIO()
|
out = StringIO()
|
||||||
call_command("axes_reset_ip", "10.0.0.3", stdout=out)
|
call_command("axes_reset_ip", "10.0.0.3", stdout=out)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue