mirror of
https://github.com/jazzband/django-axes.git
synced 2026-05-14 18:43:12 +00:00
Compare commits
331 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b14b78a16e | ||
|
|
e4cdd72231 | ||
|
|
3fc256c8d2 | ||
|
|
1aa8509cdc | ||
|
|
46e206af49 | ||
|
|
0d7f4bdb43 | ||
|
|
cc0387ae60 | ||
|
|
fdd7b22cd3 | ||
|
|
a5d14cd630 | ||
|
|
2a31c0133f | ||
|
|
4624eed684 | ||
|
|
e27ce891ea | ||
|
|
c3dcd1ba51 | ||
|
|
41ebdc3063 | ||
|
|
31c69dbea5 | ||
|
|
bdd0c9546a | ||
|
|
4b77eb69ee | ||
|
|
5acae054b4 | ||
|
|
d59a289407 | ||
|
|
23ee2fca44 | ||
|
|
4ea615811b | ||
|
|
b4fb3088b4 | ||
|
|
6c8feada83 | ||
|
|
b441ccd5fc | ||
|
|
1d9964be16 | ||
|
|
60e3cceb1d | ||
|
|
8f5e9965d8 | ||
|
|
cf0be90f11 | ||
|
|
d033b70235 | ||
|
|
b14e861631 | ||
|
|
6703b66f17 | ||
|
|
95a8043341 | ||
|
|
f2af7c993b | ||
|
|
8869a9e594 | ||
|
|
0735c71432 | ||
|
|
332a5f57d0 | ||
|
|
a30b68aec9 | ||
|
|
29005e2f6f | ||
|
|
dd172ec1a5 | ||
|
|
0d5795cdf2 | ||
|
|
2fce8fafdf | ||
|
|
53dfc9a821 | ||
|
|
9ba27a0755 | ||
|
|
6866a53728 | ||
|
|
955f39da73 | ||
|
|
04fd39fa57 | ||
|
|
69c97d5c7b | ||
|
|
3f6e773f7d | ||
|
|
88827c381e | ||
|
|
e8c3bf7be7 | ||
|
|
9962313199 | ||
|
|
cf3d3eda2c | ||
|
|
2a02585d23 | ||
|
|
13f293b650 | ||
|
|
592452e446 | ||
|
|
2a8c42c3eb | ||
|
|
29fd4bd4fe | ||
|
|
af65488dc6 | ||
|
|
e4e0299252 | ||
|
|
75c29bd6f8 | ||
|
|
95f321e7c7 | ||
|
|
bebbbe924e | ||
|
|
34a350568e | ||
|
|
8340a7a82f | ||
|
|
392dfa0e44 | ||
|
|
baace5c27b | ||
|
|
ba7b72f9d9 | ||
|
|
01ccf5b213 | ||
|
|
d8e6c939fe | ||
|
|
94a66c7346 | ||
|
|
f5951e966c | ||
|
|
f583e93718 | ||
|
|
74c24c0e78 | ||
|
|
df8fb35e18 | ||
|
|
a1e9eff875 | ||
|
|
0fd9ccd1d4 | ||
|
|
864dfc2d9a | ||
|
|
d1fad02076 | ||
|
|
d79c7de4e5 | ||
|
|
c9f092a3be | ||
|
|
9becd0061e | ||
|
|
7e495fb5fd | ||
|
|
6cb8dc7a46 | ||
|
|
a340dec892 | ||
|
|
eea9939a45 | ||
|
|
31038278bd | ||
|
|
a58344c3ef | ||
|
|
9bc11398f4 | ||
|
|
dfa39d07c0 | ||
|
|
6d2c7b1431 | ||
|
|
bd3b56237d | ||
|
|
8356498a44 | ||
|
|
933756090a | ||
|
|
3ff5ada46d | ||
|
|
4115d59d14 | ||
|
|
5e7fbca52c | ||
|
|
599fbc0da0 | ||
|
|
b4792ff868 | ||
|
|
82a6ac63bb | ||
|
|
93d8285006 | ||
|
|
0115648a1d | ||
|
|
479a355d22 | ||
|
|
fdf22fffba | ||
|
|
682e4261c9 | ||
|
|
133f19b2f5 | ||
|
|
3e3da350ea | ||
|
|
ff9c3296ef | ||
|
|
6aedd78c0b | ||
|
|
be18f038f9 | ||
|
|
04638811d7 | ||
|
|
a0df8ae7c4 | ||
|
|
784f1930af | ||
|
|
8e600536b1 | ||
|
|
d590dd6fb9 | ||
|
|
0dab4d36cf | ||
|
|
09145a8fc7 | ||
|
|
4e21791ed6 | ||
|
|
c354217ee4 | ||
|
|
6ea4879c55 | ||
|
|
78de78261d | ||
|
|
8e0c2ec4b7 | ||
|
|
a0fd10da4c | ||
|
|
2fb772efdb | ||
|
|
129e93cc0e | ||
|
|
ce3bfd51be | ||
|
|
9a7673a47e | ||
|
|
4c3a36cf9a | ||
|
|
d17e4ecd4b | ||
|
|
4511695e9f | ||
|
|
43965514cb | ||
|
|
01c32f051f | ||
|
|
71bcfba42d | ||
|
|
67b94d0dfb | ||
|
|
9acda1f892 | ||
|
|
77ae2a2d14 | ||
|
|
d52be951a1 | ||
|
|
4e89d72b92 | ||
|
|
b54019fa0f | ||
|
|
8ed0d82384 | ||
|
|
a304380853 | ||
|
|
510c8d18f5 | ||
|
|
3f4526e8f5 | ||
|
|
2fb4c81243 | ||
|
|
3fa7fce3ad | ||
|
|
66fbadff06 | ||
|
|
200351d574 | ||
|
|
dfa1eecfbf | ||
|
|
3804e834a8 | ||
|
|
0fb08fed86 | ||
|
|
f34ee54f98 | ||
|
|
b685bfa80e | ||
|
|
a4e48ed004 | ||
|
|
b00297c01a | ||
|
|
07483c6a15 | ||
|
|
58498c9716 | ||
|
|
755c416198 | ||
|
|
3a69c3ec3b | ||
|
|
9472011ed8 | ||
|
|
574ab456d7 | ||
|
|
bfe66130fb | ||
|
|
f0be0d3b8a | ||
|
|
9f350d52c0 | ||
|
|
2c4a4f6f8d | ||
|
|
585d5a7bb9 | ||
|
|
ef7e8467b4 | ||
|
|
dcc8274f87 | ||
|
|
0f6f62177d | ||
|
|
b4863515a9 | ||
|
|
afdacb2548 | ||
|
|
3161af86e6 | ||
|
|
1e442d7d39 | ||
|
|
a70ea19015 | ||
|
|
1637952978 | ||
|
|
b737f719e7 | ||
|
|
b2aec4377a | ||
|
|
1731602eb0 | ||
|
|
2f142c4300 | ||
|
|
6202062f91 | ||
|
|
aac7e67249 | ||
|
|
e2f1e28c43 | ||
|
|
754c03a22d | ||
|
|
1a586c09f0 | ||
|
|
01431c5ee5 | ||
|
|
b2db6fb6ba | ||
|
|
8724405016 | ||
|
|
3386c85b24 | ||
|
|
f70138ef5e | ||
|
|
1ddfcfa9ff | ||
|
|
1f17ee0b11 | ||
|
|
a5a25b2cd4 | ||
|
|
324c4b3b70 | ||
|
|
0965dfc874 | ||
|
|
4b34437294 | ||
|
|
0a23b4130b | ||
|
|
830728e56c | ||
|
|
014483c65d | ||
|
|
a2755bf4c8 | ||
|
|
480ccde882 | ||
|
|
78bf8ffaf6 | ||
|
|
ffe4fbf73d | ||
|
|
43b5017086 | ||
|
|
6d3cd021ac | ||
|
|
61c6dd9a08 | ||
|
|
fd9d185ad3 | ||
|
|
5e01c6eced | ||
|
|
68c122855a | ||
|
|
dabedb4d9f | ||
|
|
69d394029b | ||
|
|
462fa56c67 | ||
|
|
6e63f47a78 | ||
|
|
3e6c9ed0de | ||
|
|
dba7d4ef6c | ||
|
|
075b8d6f51 | ||
|
|
6edb4d0f33 | ||
|
|
33b76271b8 | ||
|
|
6fda722179 | ||
|
|
82ec4b92a3 | ||
|
|
3d547cd9db | ||
|
|
d44631188c | ||
|
|
61ce115eea | ||
|
|
6c54cf4e29 | ||
|
|
3986334ce9 | ||
|
|
a069a42b50 | ||
|
|
0de5ab364c | ||
|
|
048b1f9e99 | ||
|
|
9d847e50bb | ||
|
|
dbd16dd5b0 | ||
|
|
92f038e4af | ||
|
|
c62f5d3198 | ||
|
|
ef552bcc62 | ||
|
|
d224dd11ad | ||
|
|
12eca00d5c | ||
|
|
31e4a190b7 | ||
|
|
7b2b66c539 | ||
|
|
875053a9a8 | ||
|
|
00318b2bf9 | ||
|
|
89ee887085 | ||
|
|
382e1b35af | ||
|
|
45c8119604 | ||
|
|
885ac8ddfb | ||
|
|
ab54d68ee1 | ||
|
|
f9bb78ff4a | ||
|
|
bba4c80989 | ||
|
|
b583c8d0ed | ||
|
|
493789640b | ||
|
|
ea95552533 | ||
|
|
448d0cea1a | ||
|
|
2a33b2dfd0 | ||
|
|
d1faa7c413 | ||
|
|
5d19320179 | ||
|
|
d858c12c1a | ||
|
|
b3286ad177 | ||
|
|
87bdcbee3e | ||
|
|
02019db6ee | ||
|
|
add22bd1de | ||
|
|
38e316ca87 | ||
|
|
95507857f0 | ||
|
|
1aa273d99a | ||
|
|
55f9272827 | ||
|
|
7fef7df8aa | ||
|
|
eee5a69a4a | ||
|
|
1394e1aa27 | ||
|
|
73d4b71ccb | ||
|
|
bd55542b80 | ||
|
|
6f2584b440 | ||
|
|
2ee7ea1731 | ||
|
|
25e824f666 | ||
|
|
2a0fd0cfad | ||
|
|
2416fcc358 | ||
|
|
73e29645a0 | ||
|
|
6aaaf23b27 | ||
|
|
81335e1da0 | ||
|
|
aafc24e479 | ||
|
|
d2b65aad32 | ||
|
|
6e64c62fbf | ||
|
|
45d61b21ae | ||
|
|
c9556f9783 | ||
|
|
219b465bc2 | ||
|
|
6f688acb0f | ||
|
|
a9f58ef150 | ||
|
|
6b175c2c20 | ||
|
|
033ea906ef | ||
|
|
846b71ea01 | ||
|
|
bd709ec4a6 | ||
|
|
10abb732d6 | ||
|
|
ac0a419a27 | ||
|
|
95d46c638f | ||
|
|
f64c1526d7 | ||
|
|
efe08b7d8b | ||
|
|
734117c432 | ||
|
|
52eaa112dc | ||
|
|
f65b74121e | ||
|
|
6d136ee6e6 | ||
|
|
6d039adb31 | ||
|
|
eb7b703a70 | ||
|
|
da9c4f6d71 | ||
|
|
36f828b4f5 | ||
|
|
c57bac8aa2 | ||
|
|
b5f464ef96 | ||
|
|
28849e0e61 | ||
|
|
c007839e30 | ||
|
|
e781d67514 | ||
|
|
48bda54c30 | ||
|
|
4861f3c988 | ||
|
|
5f52f4383f | ||
|
|
fa8d059977 | ||
|
|
f6d5aa043d | ||
|
|
fe4aa82ca1 | ||
|
|
9861ac6404 | ||
|
|
b14e586f16 | ||
|
|
976399e30a | ||
|
|
3e54945ef5 | ||
|
|
c4fa363398 | ||
|
|
6d88064e24 | ||
|
|
0688526722 | ||
|
|
16cc250622 | ||
|
|
c1884d257a | ||
|
|
7d1ae3bca2 | ||
|
|
2e9b3e485c | ||
|
|
c80c812296 | ||
|
|
634d54ba3d | ||
|
|
61da8fbdfc | ||
|
|
e9db2d9631 | ||
|
|
ab78724518 | ||
|
|
c234f53e0d | ||
|
|
72f3be394d | ||
|
|
9a54187a65 | ||
|
|
ebe1b74925 | ||
|
|
b5a3e7ddf5 | ||
|
|
cf8ffdda60 | ||
|
|
1d4e1e3252 |
49 changed files with 1385 additions and 277 deletions
|
|
@ -4,6 +4,7 @@ about: Create a report to help us improve django-axes
|
||||||
title: 'BUG: Short description of the problem'
|
title: 'BUG: Short description of the problem'
|
||||||
labels: 'bug'
|
labels: 'bug'
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Describe the bug**
|
**Describe the bug**
|
||||||
|
|
@ -4,6 +4,7 @@ about: Suggest an idea for django-axes
|
||||||
title: 'FEATURE REQUEST: Short description of requested feature'
|
title: 'FEATURE REQUEST: Short description of requested feature'
|
||||||
labels: 'feature request'
|
labels: 'feature request'
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
**Is your feature request related to a problem? Please describe.**
|
||||||
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@v3
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v2
|
uses: github/codeql-action/init@v4
|
||||||
# Override language selection by uncommenting this and choosing your languages
|
# Override language selection by uncommenting this and choosing your languages
|
||||||
# with:
|
# with:
|
||||||
# languages: go, javascript, csharp, python, cpp, java
|
# languages: go, javascript, csharp, python, cpp, java
|
||||||
|
|
@ -26,7 +26,7 @@ jobs:
|
||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below).
|
# If this step fails, then you should remove it and run the build manually (see below).
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v2
|
uses: github/codeql-action/autobuild@v4
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
|
|
@ -40,4 +40,4 @@ jobs:
|
||||||
# make release
|
# make release
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v2
|
uses: github/codeql-action/analyze@v4
|
||||||
|
|
|
||||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -14,14 +14,14 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: 3.8
|
python-version: 3.12
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
35
.github/workflows/test.yml
vendored
35
.github/workflows/test.yml
vendored
|
|
@ -11,39 +11,32 @@ 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.8', '3.9', '3.10', '3.11']
|
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
||||||
django-version: ['3.2', '4.1', '4.2']
|
django-version: ['4.2', '5.2', '6.0']
|
||||||
include:
|
include:
|
||||||
# Tox configuration for QA environment
|
# Tox configuration for QA environment
|
||||||
- python-version: '3.11'
|
- python-version: '3.14'
|
||||||
django-version: 'qa'
|
django-version: 'qa'
|
||||||
# Django main
|
# Django main
|
||||||
- python-version: '3.11'
|
- python-version: '3.14'
|
||||||
django-version: 'main'
|
django-version: 'main'
|
||||||
experimental: true
|
experimental: true
|
||||||
# PyPy 3.8
|
|
||||||
- python-version: 'pypy-3.8'
|
|
||||||
django-version: '3.2'
|
|
||||||
experimental: true
|
|
||||||
- python-version: 'pypy-3.8'
|
|
||||||
django-version: '4.1'
|
|
||||||
experimental: true
|
|
||||||
- python-version: 'pypy-3.8'
|
|
||||||
django-version: '4.2'
|
|
||||||
experimental: true
|
|
||||||
exclude:
|
exclude:
|
||||||
# Exclude Python 3.11 for Django 3.2 and Django 4.0
|
- python-version: '3.13'
|
||||||
|
django-version: '4.2'
|
||||||
|
- python-version: '3.9'
|
||||||
|
django-version: '5.2'
|
||||||
|
- python-version: '3.10'
|
||||||
|
django-version: '6.0'
|
||||||
- python-version: '3.11'
|
- python-version: '3.11'
|
||||||
django-version: '3.2'
|
django-version: '6.0'
|
||||||
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
|
@ -53,7 +46,7 @@ jobs:
|
||||||
echo "::set-output name=dir::$(pip cache dir)"
|
echo "::set-output name=dir::$(pip cache dir)"
|
||||||
|
|
||||||
- name: Cache
|
- name: Cache
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pip-cache.outputs.dir }}
|
path: ${{ steps.pip-cache.outputs.dir }}
|
||||||
key:
|
key:
|
||||||
|
|
|
||||||
15
.readthedocs.yaml
Normal file
15
.readthedocs.yaml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# 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
|
||||||
157
CHANGES.rst
157
CHANGES.rst
|
|
@ -2,6 +2,163 @@
|
||||||
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)
|
6.0.5 (2023-07-01)
|
||||||
------------------
|
------------------
|
||||||
|
|
|
||||||
|
|
@ -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 whitelisting and blacklisting,
|
Axes supports cool off periods, IP address allow listing and block listing,
|
||||||
user account whitelisting, and other features for Django access management.
|
user account allow listing, and other features for Django access management.
|
||||||
|
|
||||||
|
|
||||||
Documentation
|
Documentation
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,3 @@
|
||||||
try:
|
from importlib.metadata import version
|
||||||
from importlib.metadata import version # New in Python 3.8
|
|
||||||
except ImportError:
|
|
||||||
from pkg_resources import get_distribution # from setuptools, deprecated
|
|
||||||
|
|
||||||
__version__ = get_distribution("django-axes").version
|
__version__ = version("django-axes")
|
||||||
else:
|
|
||||||
__version__ = version("django-axes")
|
|
||||||
|
|
|
||||||
|
|
@ -4,26 +4,59 @@ from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from axes.conf import settings
|
from axes.conf import settings
|
||||||
from axes.models import AccessAttempt, AccessLog, AccessFailureLog
|
from axes.models import AccessAttempt, AccessLog, AccessFailureLog
|
||||||
|
from axes.handlers.database import AxesDatabaseHandler
|
||||||
|
|
||||||
|
|
||||||
|
class IsLockedOutFilter(admin.SimpleListFilter):
|
||||||
|
title = _("Locked Out")
|
||||||
|
parameter_name = "locked_out"
|
||||||
|
|
||||||
|
def lookups(self, request, model_admin):
|
||||||
|
return (
|
||||||
|
("yes", _("Yes")),
|
||||||
|
("no", _("No")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def queryset(self, request, queryset):
|
||||||
|
if self.value() == "yes":
|
||||||
|
return queryset.filter(
|
||||||
|
failures_since_start__gte=settings.AXES_FAILURE_LIMIT
|
||||||
|
)
|
||||||
|
if self.value() == "no":
|
||||||
|
return queryset.filter(failures_since_start__lt=settings.AXES_FAILURE_LIMIT)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
class AccessAttemptAdmin(admin.ModelAdmin):
|
class AccessAttemptAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = [
|
||||||
"attempt_time",
|
"attempt_time",
|
||||||
"ip_address",
|
"ip_address",
|
||||||
"user_agent",
|
"user_agent",
|
||||||
"username",
|
"username",
|
||||||
"path_info",
|
"path_info",
|
||||||
"failures_since_start",
|
"failures_since_start",
|
||||||
)
|
]
|
||||||
|
|
||||||
|
if settings.AXES_USE_ATTEMPT_EXPIRATION:
|
||||||
|
list_display.append("expiration")
|
||||||
|
|
||||||
list_filter = ["attempt_time", "path_info"]
|
list_filter = ["attempt_time", "path_info"]
|
||||||
|
|
||||||
|
if isinstance(settings.AXES_FAILURE_LIMIT, int) and settings.AXES_FAILURE_LIMIT > 0:
|
||||||
|
# This will only add the status field if AXES_FAILURE_LIMIT is set to a positive integer
|
||||||
|
# Because callable failure limit requires scope of request object
|
||||||
|
list_display.append("status")
|
||||||
|
list_filter.append(IsLockedOutFilter) # type: ignore[arg-type]
|
||||||
|
|
||||||
search_fields = ["ip_address", "username", "user_agent", "path_info"]
|
search_fields = ["ip_address", "username", "user_agent", "path_info"]
|
||||||
|
|
||||||
date_hierarchy = "attempt_time"
|
date_hierarchy = "attempt_time"
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("username", "path_info", "failures_since_start")}),
|
(
|
||||||
|
None,
|
||||||
|
{"fields": ("username", "path_info", "failures_since_start", "expiration")},
|
||||||
|
),
|
||||||
(_("Form Data"), {"fields": ("get_data", "post_data")}),
|
(_("Form Data"), {"fields": ("get_data", "post_data")}),
|
||||||
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
|
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
|
||||||
)
|
)
|
||||||
|
|
@ -38,11 +71,34 @@ class AccessAttemptAdmin(admin.ModelAdmin):
|
||||||
"get_data",
|
"get_data",
|
||||||
"post_data",
|
"post_data",
|
||||||
"failures_since_start",
|
"failures_since_start",
|
||||||
|
"expiration",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
actions = ["cleanup_expired_attempts"]
|
||||||
|
|
||||||
|
@admin.action(description=_("Clean up expired attempts"))
|
||||||
|
def cleanup_expired_attempts(self, request, queryset): # noqa
|
||||||
|
count = self.handler.clean_expired_user_attempts(request=request)
|
||||||
|
self.message_user(request, _(f"Cleaned up {count} expired access attempts."))
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.handler = AxesDatabaseHandler()
|
||||||
|
|
||||||
def has_add_permission(self, request: HttpRequest) -> bool:
|
def has_add_permission(self, request: HttpRequest) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def expiration(self, obj: AccessAttempt):
|
||||||
|
return obj.expiration.expires_at if hasattr(obj, "expiration") else _("Not set")
|
||||||
|
|
||||||
|
def status(self, obj: AccessAttempt):
|
||||||
|
return (
|
||||||
|
f"{settings.AXES_FAILURE_LIMIT - obj.failures_since_start} "
|
||||||
|
+ _("Attempt Remaining")
|
||||||
|
if obj.failures_since_start < settings.AXES_FAILURE_LIMIT
|
||||||
|
else _("Locked Out")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AccessLogAdmin(admin.ModelAdmin):
|
class AccessLogAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,11 @@ class AppConfig(apps.AppConfig):
|
||||||
else:
|
else:
|
||||||
mode = "blocking by " + " or ".join(
|
mode = "blocking by " + " or ".join(
|
||||||
[
|
[
|
||||||
param
|
(
|
||||||
if isinstance(param, str)
|
param
|
||||||
else "combination of " + " and ".join(param)
|
if isinstance(param, str)
|
||||||
|
else "combination of " + " and ".join(param)
|
||||||
|
)
|
||||||
for param in settings.AXES_LOCKOUT_PARAMETERS
|
for param in settings.AXES_LOCKOUT_PARAMETERS
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,106 +1,26 @@
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from django.db.models import QuerySet
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.utils.timezone import datetime, now
|
from django.utils.timezone import datetime, now
|
||||||
|
|
||||||
from axes.conf import settings
|
from axes.helpers import get_cool_off
|
||||||
from axes.helpers import get_client_username, get_client_parameters, get_cool_off
|
|
||||||
from axes.models import AccessAttempt
|
|
||||||
|
|
||||||
log = getLogger(__name__)
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_cool_off_threshold(attempt_time: Optional[datetime] = None) -> datetime:
|
def get_cool_off_threshold(request: Optional[HttpRequest] = 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()
|
cool_off = get_cool_off(request)
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ class Messages:
|
||||||
BACKEND_INVALID = "You do not have 'axes.backends.AxesStandaloneBackend' or a subclass in your settings.AUTHENTICATION_BACKENDS."
|
BACKEND_INVALID = "You do not have 'axes.backends.AxesStandaloneBackend' or a subclass in your settings.AUTHENTICATION_BACKENDS."
|
||||||
SETTING_DEPRECATED = "You have a deprecated setting {deprecated_setting} configured in your project settings"
|
SETTING_DEPRECATED = "You have a deprecated setting {deprecated_setting} configured in your project settings"
|
||||||
CALLABLE_INVALID = "{callable_setting} is not a valid callable."
|
CALLABLE_INVALID = "{callable_setting} is not a valid callable."
|
||||||
|
LOCKOUT_PARAMETERS_INVALID = (
|
||||||
|
"AXES_LOCKOUT_PARAMETERS does not contain 'ip_address'."
|
||||||
|
" This configuration allows attackers to bypass rate limits by rotating User-Agents or Cookies."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Hints:
|
class Hints:
|
||||||
|
|
@ -30,6 +34,7 @@ class Hints:
|
||||||
BACKEND_INVALID = "AxesModelBackend was renamed to AxesStandaloneBackend in django-axes version 5.0."
|
BACKEND_INVALID = "AxesModelBackend was renamed to AxesStandaloneBackend in django-axes version 5.0."
|
||||||
SETTING_DEPRECATED = None
|
SETTING_DEPRECATED = None
|
||||||
CALLABLE_INVALID = None
|
CALLABLE_INVALID = None
|
||||||
|
LOCKOUT_PARAMETERS_INVALID = "Add 'ip_address' to AXES_LOCKOUT_PARAMETERS."
|
||||||
|
|
||||||
|
|
||||||
class Codes:
|
class Codes:
|
||||||
|
|
@ -38,6 +43,7 @@ class Codes:
|
||||||
BACKEND_INVALID = "axes.W003"
|
BACKEND_INVALID = "axes.W003"
|
||||||
SETTING_DEPRECATED = "axes.W004"
|
SETTING_DEPRECATED = "axes.W004"
|
||||||
CALLABLE_INVALID = "axes.W005"
|
CALLABLE_INVALID = "axes.W005"
|
||||||
|
LOCKOUT_PARAMETERS_INVALID = "axes.W006"
|
||||||
|
|
||||||
|
|
||||||
@register(Tags.security, Tags.caches, Tags.compatibility)
|
@register(Tags.security, Tags.caches, Tags.compatibility)
|
||||||
|
|
@ -158,6 +164,34 @@ def axes_deprecation_check(app_configs, **kwargs): # pylint: disable=unused-arg
|
||||||
return warnings
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
@register(Tags.security)
|
||||||
|
def axes_lockout_params_check(app_configs, **kwargs): # pylint: disable=unused-argument
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
lockout_params = getattr(settings, "AXES_LOCKOUT_PARAMETERS", None)
|
||||||
|
|
||||||
|
if isinstance(lockout_params, (list, tuple)):
|
||||||
|
has_ip = False
|
||||||
|
for param in lockout_params:
|
||||||
|
if param == "ip_address":
|
||||||
|
has_ip = True
|
||||||
|
break
|
||||||
|
if isinstance(param, (list, tuple)) and "ip_address" in param:
|
||||||
|
has_ip = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not has_ip:
|
||||||
|
warnings.append(
|
||||||
|
Warning(
|
||||||
|
msg=Messages.LOCKOUT_PARAMETERS_INVALID,
|
||||||
|
hint=Hints.LOCKOUT_PARAMETERS_INVALID,
|
||||||
|
id=Codes.LOCKOUT_PARAMETERS_INVALID,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
@register
|
@register
|
||||||
def axes_conf_check(app_configs, **kwargs): # pylint: disable=unused-argument
|
def axes_conf_check(app_configs, **kwargs): # pylint: disable=unused-argument
|
||||||
warnings = []
|
warnings = []
|
||||||
|
|
@ -173,7 +207,7 @@ def axes_conf_check(app_configs, **kwargs): # pylint: disable=unused-argument
|
||||||
]
|
]
|
||||||
|
|
||||||
for callable_setting in callable_settings:
|
for callable_setting in callable_settings:
|
||||||
value = getattr(settings, callable_setting)
|
value = getattr(settings, callable_setting, None)
|
||||||
if not is_valid_callable(value):
|
if not is_valid_callable(value):
|
||||||
warnings.append(
|
warnings.append(
|
||||||
Warning(
|
Warning(
|
||||||
|
|
|
||||||
30
axes/conf.py
30
axes/conf.py
|
|
@ -1,6 +1,21 @@
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils.functional import SimpleLazyObject
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class JSONSerializableLazyObject(SimpleLazyObject):
|
||||||
|
"""
|
||||||
|
Celery/Kombu config inspection may JSON-encode Django settings.
|
||||||
|
Provide a JSON-friendly representation for lazy values.
|
||||||
|
|
||||||
|
Fixes jazzband/django-axes#1391
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __json__(self):
|
||||||
|
return str(self)
|
||||||
|
|
||||||
|
|
||||||
# disable plugin when set to False
|
# disable plugin when set to False
|
||||||
settings.AXES_ENABLED = getattr(settings, "AXES_ENABLED", True)
|
settings.AXES_ENABLED = getattr(settings, "AXES_ENABLED", True)
|
||||||
|
|
||||||
|
|
@ -41,9 +56,16 @@ settings.AXES_ONLY_ADMIN_SITE = getattr(settings, "AXES_ONLY_ADMIN_SITE", False)
|
||||||
# show Axes logs in admin
|
# show Axes logs in admin
|
||||||
settings.AXES_ENABLE_ADMIN = getattr(settings, "AXES_ENABLE_ADMIN", True)
|
settings.AXES_ENABLE_ADMIN = getattr(settings, "AXES_ENABLE_ADMIN", True)
|
||||||
|
|
||||||
|
|
||||||
# use a specific username field to retrieve from login POST data
|
# use a specific username field to retrieve from login POST data
|
||||||
|
def _get_username_field_default():
|
||||||
|
return get_user_model().USERNAME_FIELD
|
||||||
|
|
||||||
|
|
||||||
settings.AXES_USERNAME_FORM_FIELD = getattr(
|
settings.AXES_USERNAME_FORM_FIELD = getattr(
|
||||||
settings, "AXES_USERNAME_FORM_FIELD", "username"
|
settings,
|
||||||
|
"AXES_USERNAME_FORM_FIELD",
|
||||||
|
JSONSerializableLazyObject(_get_username_field_default),
|
||||||
)
|
)
|
||||||
|
|
||||||
# use a specific password field to retrieve from login POST data
|
# use a specific password field to retrieve from login POST data
|
||||||
|
|
@ -86,6 +108,10 @@ settings.AXES_LOCKOUT_URL = getattr(settings, "AXES_LOCKOUT_URL", None)
|
||||||
|
|
||||||
settings.AXES_COOLOFF_TIME = getattr(settings, "AXES_COOLOFF_TIME", None)
|
settings.AXES_COOLOFF_TIME = getattr(settings, "AXES_COOLOFF_TIME", None)
|
||||||
|
|
||||||
|
settings.AXES_USE_ATTEMPT_EXPIRATION = getattr(
|
||||||
|
settings, "AXES_USE_ATTEMPT_EXPIRATION", False
|
||||||
|
)
|
||||||
|
|
||||||
settings.AXES_VERBOSE = getattr(settings, "AXES_VERBOSE", settings.AXES_ENABLED)
|
settings.AXES_VERBOSE = getattr(settings, "AXES_VERBOSE", settings.AXES_ENABLED)
|
||||||
|
|
||||||
# whitelist and blacklist
|
# whitelist and blacklist
|
||||||
|
|
@ -124,7 +150,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()
|
cache_timeout = get_cache_timeout(request)
|
||||||
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,27 +1,32 @@
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import router, transaction
|
||||||
from django.db.models import F, Sum, Value, Q
|
from django.db.models import F, Q, QuerySet, Sum, Value
|
||||||
from django.db.models.functions import Concat
|
from django.db.models.functions import Concat
|
||||||
|
from django.http import HttpRequest
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from axes.attempts import (
|
from axes.attempts import get_cool_off_threshold
|
||||||
clean_expired_user_attempts,
|
|
||||||
get_user_attempts,
|
|
||||||
reset_user_attempts,
|
|
||||||
)
|
|
||||||
from axes.conf import settings
|
from axes.conf import settings
|
||||||
from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler
|
from axes.handlers.base import AbstractAxesHandler, AxesBaseHandler
|
||||||
from axes.helpers import (
|
from axes.helpers import (
|
||||||
|
get_client_parameters,
|
||||||
|
get_client_session_hash,
|
||||||
get_client_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__)
|
||||||
|
|
@ -103,7 +108,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def get_failures(self, request, credentials: Optional[dict] = None) -> int:
|
def get_failures(self, request, credentials: Optional[dict] = None) -> int:
|
||||||
attempts_list = get_user_attempts(request, credentials)
|
attempts_list = self.get_user_attempts(request, credentials)
|
||||||
attempt_count = max(
|
attempt_count = max(
|
||||||
(
|
(
|
||||||
attempts.aggregate(Sum("failures_since_start"))[
|
attempts.aggregate(Sum("failures_since_start"))[
|
||||||
|
|
@ -116,10 +121,10 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
return attempt_count
|
return attempt_count
|
||||||
|
|
||||||
def user_login_failed(self, sender, credentials: dict, request=None, **kwargs):
|
def user_login_failed(self, sender, credentials: dict, request=None, **kwargs):
|
||||||
"""When user login fails, save AccessFailureLog record in database,
|
"""
|
||||||
|
When user login fails, save AccessFailureLog record in database,
|
||||||
save AccessAttempt record in database, mark request with
|
save AccessAttempt record in database, mark request with
|
||||||
lockout attribute and emit lockout signal.
|
lockout attribute and emit lockout signal.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
log.info("AXES: User login failed, running database handler for failure.")
|
log.info("AXES: User login failed, running database handler for failure.")
|
||||||
|
|
@ -131,7 +136,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
return
|
return
|
||||||
|
|
||||||
# 1. database query: Clean up expired user attempts from the database before logging new attempts
|
# 1. database query: Clean up expired user attempts from the database before logging new attempts
|
||||||
clean_expired_user_attempts(request.axes_attempt_time)
|
self.clean_expired_user_attempts(request, credentials)
|
||||||
|
|
||||||
username = get_client_username(request, credentials)
|
username = get_client_username(request, credentials)
|
||||||
client_str = get_client_str(
|
client_str = get_client_str(
|
||||||
|
|
@ -171,7 +176,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():
|
with transaction.atomic(using=router.db_for_write(AccessAttempt)):
|
||||||
(
|
(
|
||||||
attempt,
|
attempt,
|
||||||
created,
|
created,
|
||||||
|
|
@ -220,6 +225,23 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
client_str,
|
client_str,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if settings.AXES_USE_ATTEMPT_EXPIRATION:
|
||||||
|
if not hasattr(attempt, "expiration") or attempt.expiration is None:
|
||||||
|
log.debug(
|
||||||
|
"AXES: Creating new AccessAttemptExpiration for %s",
|
||||||
|
client_str,
|
||||||
|
)
|
||||||
|
attempt.expiration = AccessAttemptExpiration.objects.create(
|
||||||
|
access_attempt=attempt,
|
||||||
|
expires_at=get_attempt_expiration(request),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
attempt.expiration.expires_at = max(
|
||||||
|
get_attempt_expiration(request),
|
||||||
|
attempt.expiration.expires_at,
|
||||||
|
)
|
||||||
|
attempt.expiration.save()
|
||||||
|
|
||||||
# 3. or 4. database query: Calculate the current maximum failure number from the existing attempts
|
# 3. or 4. database query: Calculate the current maximum failure number from the existing attempts
|
||||||
failures_since_start = self.get_failures(request, credentials)
|
failures_since_start = self.get_failures(request, credentials)
|
||||||
request.axes_failures_since_start = failures_since_start
|
request.axes_failures_since_start = failures_since_start
|
||||||
|
|
@ -243,7 +265,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():
|
with transaction.atomic(using=router.db_for_write(AccessFailureLog)):
|
||||||
AccessFailureLog.objects.create(
|
AccessFailureLog.objects.create(
|
||||||
username=username,
|
username=username,
|
||||||
ip_address=request.axes_ip_address,
|
ip_address=request.axes_ip_address,
|
||||||
|
|
@ -260,9 +282,6 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
When user logs in, update the AccessLog related to the user.
|
When user logs in, update the AccessLog related to the user.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 1. database query: Clean up expired user attempts from the database
|
|
||||||
clean_expired_user_attempts(request.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(
|
||||||
|
|
@ -275,6 +294,9 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
|
|
||||||
log.info("AXES: Successful login by %s.", client_str)
|
log.info("AXES: Successful login by %s.", client_str)
|
||||||
|
|
||||||
|
# 1. database query: Clean up expired user attempts from the database
|
||||||
|
self.clean_expired_user_attempts(request, credentials)
|
||||||
|
|
||||||
if not settings.AXES_DISABLE_ACCESS_LOG:
|
if not settings.AXES_DISABLE_ACCESS_LOG:
|
||||||
# 2. database query: Insert new access logs with login time
|
# 2. database query: Insert new access logs with login time
|
||||||
AccessLog.objects.create(
|
AccessLog.objects.create(
|
||||||
|
|
@ -284,11 +306,14 @@ 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 = reset_user_attempts(request, credentials)
|
count = self.reset_user_attempts(request, credentials)
|
||||||
log.info(
|
log.info(
|
||||||
"AXES: Deleted %d failed login attempts by %s from database.",
|
"AXES: Deleted %d failed login attempts by %s from database.",
|
||||||
count,
|
count,
|
||||||
|
|
@ -300,10 +325,8 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||||
When user logs out, update the AccessLog related to the user.
|
When user logs out, update the AccessLog related to the user.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 1. database query: Clean up expired user attempts from the database
|
|
||||||
clean_expired_user_attempts(request.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,
|
||||||
|
|
@ -312,14 +335,117 @@ 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, logout_time__isnull=True
|
username=username,
|
||||||
|
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
|
from datetime import timedelta, datetime
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from string import Template
|
from string import Template
|
||||||
|
|
@ -8,6 +8,7 @@ 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
|
||||||
|
|
@ -31,32 +32,33 @@ def get_cache() -> BaseCache:
|
||||||
return caches[getattr(settings, "AXES_CACHE", "default")]
|
return caches[getattr(settings, "AXES_CACHE", "default")]
|
||||||
|
|
||||||
|
|
||||||
def get_cache_timeout() -> Optional[int]:
|
def get_cache_timeout(request: Optional[HttpRequest] = None) -> 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, integer, callable, or str path,
|
Notice that the settings.AXES_COOLOFF_TIME can be None, timedelta, float, 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()
|
cool_off = get_cool_off(request)
|
||||||
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() -> Optional[timedelta]:
|
def get_cool_off(request: Optional[HttpRequest] = None) -> 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, or integer of hours,
|
Notice that the settings.AXES_COOLOFF_TIME is either None, timedelta, integer/float of hours,
|
||||||
and this function offers a unified _timedelta or None_ representation of that configuration
|
a path to a callable or a callable taking 1 argument (the request). This function
|
||||||
for use with the Axes internal implementations.
|
offers a unified _timedelta or None_ representation of that configuration for use with the
|
||||||
|
Axes internal implementations.
|
||||||
|
|
||||||
:exception TypeError: if settings.AXES_COOLOFF_TIME is of wrong type.
|
:exception TypeError: if settings.AXES_COOLOFF_TIME is of wrong type.
|
||||||
"""
|
"""
|
||||||
|
|
@ -68,9 +70,10 @@ def get_cool_off() -> 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):
|
||||||
return import_string(cool_off)()
|
cool_off_func = import_string(cool_off)
|
||||||
|
return cool_off_func(request)
|
||||||
if callable(cool_off):
|
if callable(cool_off):
|
||||||
return cool_off() # pylint: disable=not-callable
|
return cool_off(request) # pylint: disable=not-callable
|
||||||
|
|
||||||
return cool_off
|
return cool_off
|
||||||
|
|
||||||
|
|
@ -98,6 +101,23 @@ def get_cool_off_iso8601(delta: timedelta) -> str:
|
||||||
return f"P{days_str}"
|
return f"P{days_str}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_attempt_expiration(request: Optional[HttpRequest] = None) -> datetime:
|
||||||
|
"""
|
||||||
|
Get threshold for fetching access attempts from the database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
cool_off = get_cool_off(request)
|
||||||
|
if cool_off is None:
|
||||||
|
raise TypeError(
|
||||||
|
"Cool off threshold can not be calculated with settings.AXES_COOLOFF_TIME set to None"
|
||||||
|
)
|
||||||
|
|
||||||
|
attempt_time = request.axes_attempt_time # type: ignore[union-attr]
|
||||||
|
if attempt_time is None:
|
||||||
|
return datetime.now() + cool_off
|
||||||
|
return attempt_time + cool_off
|
||||||
|
|
||||||
|
|
||||||
def get_credentials(username: Optional[str] = None, **kwargs) -> dict:
|
def get_credentials(username: Optional[str] = None, **kwargs) -> dict:
|
||||||
"""
|
"""
|
||||||
Calculate credentials for Axes to use internally from given username and kwargs.
|
Calculate credentials for Axes to use internally from given username and kwargs.
|
||||||
|
|
@ -144,7 +164,7 @@ def get_client_username(
|
||||||
log.debug(
|
log.debug(
|
||||||
"Using parameter credentials to get username with key settings.AXES_USERNAME_FORM_FIELD"
|
"Using parameter credentials to get username with key settings.AXES_USERNAME_FORM_FIELD"
|
||||||
)
|
)
|
||||||
return credentials.get(settings.AXES_USERNAME_FORM_FIELD, None)
|
return credentials.get(settings.AXES_USERNAME_FORM_FIELD, None) # type: ignore[return-value]
|
||||||
|
|
||||||
log.debug(
|
log.debug(
|
||||||
"Using parameter request.POST to get username with key settings.AXES_USERNAME_FORM_FIELD"
|
"Using parameter request.POST to get username with key settings.AXES_USERNAME_FORM_FIELD"
|
||||||
|
|
@ -442,15 +462,27 @@ def get_lockout_message() -> str:
|
||||||
|
|
||||||
|
|
||||||
def get_lockout_response(
|
def get_lockout_response(
|
||||||
request: HttpRequest, credentials: Optional[dict] = None
|
request: HttpRequest,
|
||||||
|
original_response: Optional[HttpResponse] = None,
|
||||||
|
credentials: Optional[dict] = None,
|
||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
if settings.AXES_LOCKOUT_CALLABLE:
|
if settings.AXES_LOCKOUT_CALLABLE:
|
||||||
if callable(settings.AXES_LOCKOUT_CALLABLE):
|
if callable(settings.AXES_LOCKOUT_CALLABLE):
|
||||||
return settings.AXES_LOCKOUT_CALLABLE( # pylint: disable=not-callable
|
# Try calling with 3 args, fallback to 2 for backward compatibility
|
||||||
request, credentials
|
try:
|
||||||
)
|
return settings.AXES_LOCKOUT_CALLABLE(
|
||||||
|
request, original_response, credentials
|
||||||
|
)
|
||||||
|
except TypeError:
|
||||||
|
# Fallback: old signature without original_response
|
||||||
|
return settings.AXES_LOCKOUT_CALLABLE(request, credentials)
|
||||||
if isinstance(settings.AXES_LOCKOUT_CALLABLE, str):
|
if isinstance(settings.AXES_LOCKOUT_CALLABLE, str):
|
||||||
return import_string(settings.AXES_LOCKOUT_CALLABLE)(request, credentials)
|
callable_obj = import_string(settings.AXES_LOCKOUT_CALLABLE)
|
||||||
|
# Try calling with 3 args, fallback to 2 for backward compatibility
|
||||||
|
try:
|
||||||
|
return callable_obj(request, original_response, credentials)
|
||||||
|
except TypeError:
|
||||||
|
return callable_obj(request, credentials)
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
"settings.AXES_LOCKOUT_CALLABLE needs to be a string, callable, or None."
|
"settings.AXES_LOCKOUT_CALLABLE needs to be a string, callable, or None."
|
||||||
)
|
)
|
||||||
|
|
@ -461,7 +493,7 @@ def get_lockout_response(
|
||||||
"username": get_client_username(request, credentials) or "",
|
"username": get_client_username(request, credentials) or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
cool_off = get_cool_off()
|
cool_off = get_cool_off(request)
|
||||||
if cool_off:
|
if cool_off:
|
||||||
context.update(
|
context.update(
|
||||||
{
|
{
|
||||||
|
|
@ -474,13 +506,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[
|
json_response["Access-Control-Allow-Origin"] = (
|
||||||
"Access-Control-Allow-Origin"
|
settings.AXES_ALLOWED_CORS_ORIGINS
|
||||||
] = settings.AXES_ALLOWED_CORS_ORIGINS
|
)
|
||||||
json_response["Access-Control-Allow-Methods"] = "POST, OPTIONS"
|
json_response["Access-Control-Allow-Methods"] = "POST, OPTIONS"
|
||||||
json_response[
|
json_response["Access-Control-Allow-Headers"] = (
|
||||||
"Access-Control-Allow-Headers"
|
"Origin, Content-Type, Accept, Authorization, x-requested-with"
|
||||||
] = "Origin, Content-Type, Accept, Authorization, x-requested-with"
|
)
|
||||||
return json_response
|
return json_response
|
||||||
|
|
||||||
if settings.AXES_LOCKOUT_TEMPLATE:
|
if settings.AXES_LOCKOUT_TEMPLATE:
|
||||||
|
|
@ -614,3 +646,24 @@ 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()
|
||||||
|
|
|
||||||
BIN
axes/locale/fa/LC_MESSAGES/django.mo
Normal file
BIN
axes/locale/fa/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
109
axes/locale/fa/LC_MESSAGES/django.po
Normal file
109
axes/locale/fa/LC_MESSAGES/django.po
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
# ترجمه فارسی برای django-axes
|
||||||
|
# Copyright (C) 2025 jazzband
|
||||||
|
# This file is distributed under the same license as the django-axes package.
|
||||||
|
# AmirAli Bahramjerdi <amiralibahramjerdi@gmail.com>, 2025.
|
||||||
|
#
|
||||||
|
#, fuzzy
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: django-axes\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2025-05-16 23:28+0330\n"
|
||||||
|
"PO-Revision-Date: 2025-05-16 23:30+0330\n"
|
||||||
|
"Last-Translator: AmirAli Bahramjerdi <amiralibahramjerdi@gmail.com>"
|
||||||
|
"Language-Team: فارسی <fa@li.org>\n"
|
||||||
|
"Language: fa\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||||
|
|
||||||
|
#: admin.py:27
|
||||||
|
msgid "Form Data"
|
||||||
|
msgstr "دادههای فرم"
|
||||||
|
|
||||||
|
#: admin.py:28 admin.py:65 admin.py:100
|
||||||
|
msgid "Meta Data"
|
||||||
|
msgstr "فراداده"
|
||||||
|
|
||||||
|
#: conf.py:109
|
||||||
|
msgid "Account locked: too many login attempts. Please try again later."
|
||||||
|
msgstr "حساب کاربری قفل شد: تلاشهای زیادی برای ورود انجام شده است. لطفاً بعداً دوباره امتحان کنید."
|
||||||
|
|
||||||
|
#: conf.py:117
|
||||||
|
msgid ""
|
||||||
|
"Account locked: too many login attempts. Contact an admin to unlock your "
|
||||||
|
"account."
|
||||||
|
msgstr "حساب کاربری قفل شد: تلاشهای زیادی برای ورود انجام شده است. برای باز کردن حساب با مدیر تماس بگیرید."
|
||||||
|
|
||||||
|
#: models.py:6
|
||||||
|
msgid "User Agent"
|
||||||
|
msgstr "عامل کاربر (User Agent)"
|
||||||
|
|
||||||
|
#: models.py:8
|
||||||
|
msgid "IP Address"
|
||||||
|
msgstr "آدرس IP"
|
||||||
|
|
||||||
|
#: models.py:10
|
||||||
|
msgid "Username"
|
||||||
|
msgstr "نام کاربری"
|
||||||
|
|
||||||
|
#: models.py:12
|
||||||
|
msgid "HTTP Accept"
|
||||||
|
msgstr "پذیرش HTTP"
|
||||||
|
|
||||||
|
#: models.py:14
|
||||||
|
msgid "Path"
|
||||||
|
msgstr "مسیر"
|
||||||
|
|
||||||
|
#: models.py:16
|
||||||
|
msgid "Attempt Time"
|
||||||
|
msgstr "زمان تلاش"
|
||||||
|
|
||||||
|
#: models.py:26
|
||||||
|
msgid "Access lock out"
|
||||||
|
msgstr "قفل دسترسی"
|
||||||
|
|
||||||
|
#: models.py:34
|
||||||
|
msgid "access failure"
|
||||||
|
msgstr "شکست در دسترسی"
|
||||||
|
|
||||||
|
#: models.py:35
|
||||||
|
msgid "access failures"
|
||||||
|
msgstr "شکستهای دسترسی"
|
||||||
|
|
||||||
|
#: models.py:39
|
||||||
|
msgid "GET Data"
|
||||||
|
msgstr "دادههای GET"
|
||||||
|
|
||||||
|
#: models.py:41
|
||||||
|
msgid "POST Data"
|
||||||
|
msgstr "دادههای POST"
|
||||||
|
|
||||||
|
#: models.py:43
|
||||||
|
msgid "Failed Logins"
|
||||||
|
msgstr "ورودهای ناموفق"
|
||||||
|
|
||||||
|
#: models.py:49
|
||||||
|
msgid "access attempt"
|
||||||
|
msgstr "تلاش برای دسترسی"
|
||||||
|
|
||||||
|
#: models.py:50
|
||||||
|
msgid "access attempts"
|
||||||
|
msgstr "تلاشهای دسترسی"
|
||||||
|
|
||||||
|
#: models.py:55
|
||||||
|
msgid "Logout Time"
|
||||||
|
msgstr "زمان خروج"
|
||||||
|
|
||||||
|
#: models.py:56
|
||||||
|
msgid "Session key hash (sha256)"
|
||||||
|
msgstr "هش کلید نشست (sha256)"
|
||||||
|
|
||||||
|
#: models.py:62
|
||||||
|
msgid "access log"
|
||||||
|
msgstr "گزارش دسترسی"
|
||||||
|
|
||||||
|
#: models.py:63
|
||||||
|
msgid "access logs"
|
||||||
|
msgstr "گزارشهای دسترسی"
|
||||||
BIN
axes/locale/fr/LC_MESSAGES/django.mo
Normal file
BIN
axes/locale/fr/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
109
axes/locale/fr/LC_MESSAGES/django.po
Normal file
109
axes/locale/fr/LC_MESSAGES/django.po
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
# 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.
19
axes/management/commands/axes_reset_ip_username.py
Normal file
19
axes/management/commands/axes_reset_ip_username.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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,5 +1,6 @@
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -30,15 +31,37 @@ 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 = get_lockout_response(request, credentials) # type: ignore
|
response = await sync_to_async(
|
||||||
|
get_lockout_response, thread_sensitive=True
|
||||||
|
)(
|
||||||
|
request, credentials
|
||||||
|
) # type: ignore
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
# 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
|
from django.db import migrations, router
|
||||||
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")
|
||||||
|
|
@ -31,7 +35,9 @@ class Migration(migrations.Migration):
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RunPython(deduplicate_attempts),
|
migrations.RunPython(
|
||||||
|
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")},
|
||||||
|
|
|
||||||
22
axes/migrations/0009_add_session_hash.py
Normal file
22
axes/migrations/0009_add_session_hash.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# 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)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
41
axes/migrations/0010_accessattemptexpiration.py
Normal file
41
axes/migrations/0010_accessattemptexpiration.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Generated by Django 5.2.1 on 2025-06-10 20:21
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("axes", "0009_add_session_hash"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="AccessAttemptExpiration",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"access_attempt",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
primary_key=True,
|
||||||
|
related_name="expiration",
|
||||||
|
serialize=False,
|
||||||
|
to="axes.accessattempt",
|
||||||
|
verbose_name="Access Attempt",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"expires_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
help_text="The time when access attempt expires and is no longer valid.",
|
||||||
|
verbose_name="Expires At",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "access attempt expiration",
|
||||||
|
"verbose_name_plural": "access attempt expirations",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -51,8 +51,29 @@ class AccessAttempt(AccessBase):
|
||||||
unique_together = [["username", "ip_address", "user_agent"]]
|
unique_together = [["username", "ip_address", "user_agent"]]
|
||||||
|
|
||||||
|
|
||||||
|
class AccessAttemptExpiration(models.Model):
|
||||||
|
access_attempt = models.OneToOneField(
|
||||||
|
AccessAttempt,
|
||||||
|
primary_key=True,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="expiration",
|
||||||
|
verbose_name=_("Access Attempt"),
|
||||||
|
)
|
||||||
|
expires_at = models.DateTimeField(
|
||||||
|
_("Expires At"),
|
||||||
|
help_text=_("The time when access attempt expires and is no longer valid."),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("access attempt expiration")
|
||||||
|
verbose_name_plural = _("access attempt expirations")
|
||||||
|
|
||||||
|
|
||||||
class AccessLog(AccessBase):
|
class AccessLog(AccessBase):
|
||||||
logout_time = models.DateTimeField(_("Logout Time"), null=True, blank=True)
|
logout_time = models.DateTimeField(_("Logout Time"), null=True, blank=True)
|
||||||
|
session_hash = models.CharField(
|
||||||
|
_("Session key hash (sha256)"), default="", blank=True, max_length=64
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Access Log for {self.username} @ {self.attempt_time}"
|
return f"Access Log for {self.username} @ {self.attempt_time}"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Requirements
|
Requirements
|
||||||
============
|
============
|
||||||
|
|
||||||
Axes requires a supported Django version and runs on Python versions 3.8 and above.
|
Axes requires a supported Django version and runs on Python versions 3.9 and above.
|
||||||
|
|
||||||
Refer to the project source code repository in
|
Refer to the project source code repository in
|
||||||
`GitHub <https://github.com/jazzband/django-axes/>`_ and see the
|
`GitHub <https://github.com/jazzband/django-axes/>`_ and see the
|
||||||
|
|
|
||||||
|
|
@ -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,6 +56,9 @@ After installing the package, the project settings need to be configured.
|
||||||
# on failed user authentication attempts from login views.
|
# on failed user authentication attempts from login views.
|
||||||
# If you do not want Axes to override the authentication response
|
# If you do not want Axes to override the authentication response
|
||||||
# you can skip installing the middleware and use your own views.
|
# you can skip installing the middleware and use your own views.
|
||||||
|
# AxesMiddleware runs during the reponse phase. It does not conflict
|
||||||
|
# with middleware that runs in the request phase like
|
||||||
|
# django.middleware.cache.FetchFromCacheMiddleware.
|
||||||
'axes.middleware.AxesMiddleware',
|
'axes.middleware.AxesMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -75,6 +78,20 @@ 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
|
||||||
-------------------------------------------------------------------
|
-------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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``, and ``axes_reset_username``
|
``axes_reset``, ``axes_reset_ip``, ``axes_reset_username``, and ``axes_reset_ip_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,6 +89,8 @@ 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.
|
||||||
|
|
@ -107,3 +109,24 @@ 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,11 +19,13 @@ The following ``settings.py`` options are available for customizing Axes behavio
|
||||||
+======================================================+==============================================+===========================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================+
|
+======================================================+==============================================+===========================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================+
|
||||||
| AXES_ENABLED | True | Enable or disable Axes plugin functionality, for example in test runner setup |
|
| AXES_ENABLED | True | Enable or disable Axes plugin functionality, for example in test runner setup |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_FAILURE_LIMIT | 3 | The integer number of login attempts allowed before a record is created for the failed logins. This can also be a callable or a dotted path to callable that returns an integer and all of the following are valid: ``AXES_FAILURE_LIMIT = 42``, ``AXES_FAILURE_LIMIT = lambda *args: 42``, and ``AXES_FAILURE_LIMIT = 'project.app.get_login_failure_limit'``. |
|
| AXES_FAILURE_LIMIT | 3 | The integer number of login attempts allowed before the request is considered locked. This can also be a callable or a dotted path to callable that returns an integer and all of the following are valid: ``AXES_FAILURE_LIMIT = 42``, ``AXES_FAILURE_LIMIT = lambda *args: 42``, and ``AXES_FAILURE_LIMIT = 'project.app.get_login_failure_limit'``. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_LOCK_OUT_AT_FAILURE | True | After the number of allowed login attempts are exceeded, should we lock out this IP (and optional user agent)? |
|
| AXES_LOCK_OUT_AT_FAILURE | True | After the number of allowed login attempts are exceeded, should we lock out this IP (and optional user agent)? |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_COOLOFF_TIME | None | If set, defines a period of inactivity after which old failed login attempts will be cleared. Can be set to a Python timedelta object, an integer, a float, a callable, or a string path to a callable which takes 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_COOLOFF_TIME | None | If set, defines the cool-off period after which old failed login attempts are cleared. If ``None``, lockout is permanent until attempts are manually reset. Can be set to a Python timedelta object, an integer, a float, a callable, or a string path to a callable that takes the request as argument. If an integer or float, this is interpreted as hours (``1`` is 1 hour, ``0.5`` is 30 minutes, ``1.7`` is 6120 seconds). ``timedelta`` is recommended for clarity. See also ``AXES_USE_ATTEMPT_EXPIRATION`` for rolling-window behavior. |
|
||||||
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
|
| AXES_USE_ATTEMPT_EXPIRATION | False | If ``True``, changes ``AXES_COOLOFF_TIME`` to a rolling window where each failed attempt expires individually after the cool-off time. This enables policies like "3 failed login attempts per 15 minutes". If ``False``, ``AXES_COOLOFF_TIME`` acts as an inactivity period where attempts are cleared only after no new failures occur within the cool-off limit. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_ONLY_ADMIN_SITE | False | If ``True``, lock is only enabled for admin site. Admin site is determined by checking request path against the path of ``"admin:index"`` view. If admin urls are not registered in current urlconf, all requests will not be locked. |
|
| AXES_ONLY_ADMIN_SITE | False | If ``True``, lock is only enabled for admin site. Admin site is determined by checking request path against the path of ``"admin:index"`` view. If admin urls are not registered in current urlconf, all requests will not be locked. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
|
|
@ -47,19 +49,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 | 'username' | The name of the form field that contains your users usernames. |
|
| AXES_USERNAME_FORM_FIELD | 'settings.AUTH_USER_MODEL.USERNAME_FIELD' | The name of the form field that contains your users usernames. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_USERNAME_CALLABLE | None | A callable or a string path to callable that takes two arguments for user lookups: ``def get_username(request: HttpRequest, credentials: dict) -> str: ...``. This can be any callable such as ``AXES_USERNAME_CALLABLE = lambda request, credentials: 'username'`` or a full Python module path to callable such as ``AXES_USERNAME_CALLABLE = 'example.get_username``. The ``request`` is a HttpRequest like object and the ``credentials`` is a dictionary like object. ``credentials`` are the ones that were passed to Django ``authenticate()`` in the login flow. If no function is supplied, Axes fetches the username from the ``credentials`` or ``request.POST`` dictionaries based on ``AXES_USERNAME_FORM_FIELD``. |
|
| AXES_USERNAME_CALLABLE | None | A callable or a string path to callable that takes two arguments for user lookups: ``def get_username(request: HttpRequest, credentials: dict) -> str: ...``. This can be any callable such as ``AXES_USERNAME_CALLABLE = lambda request, credentials: 'username'`` or a full Python module path to callable such as ``AXES_USERNAME_CALLABLE = 'example.get_username``. The ``request`` is a HttpRequest like object and the ``credentials`` is a dictionary like object. ``credentials`` are the ones that were passed to Django ``authenticate()`` in the login flow. If no function is supplied, Axes fetches the username from the ``credentials`` or ``request.POST`` dictionaries based on ``AXES_USERNAME_FORM_FIELD``. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_WHITELIST_CALLABLE | None | A callable or a string path to callable that takes two arguments for whitelisting determination and returns True, if user should be whitelisted: ``def is_whitelisted(request: HttpRequest, credentials: dict) -> bool: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. |
|
| AXES_WHITELIST_CALLABLE | None | A callable or a string path to callable that takes two arguments for whitelisting determination and returns True, if user should be whitelisted: ``def is_whitelisted(request: HttpRequest, credentials: dict) -> bool: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_LOCKOUT_CALLABLE | None | A callable or a string path to callable that takes two arguments returns a response. For example: ``def generate_lockout_response(request: HttpRequest, credentials: dict) -> HttpResponse: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. If not callable is defined, then the default implementation in ``axes.helpers.get_lockout_response`` is used for determining the correct lockout response that is sent to the requesting client. |
|
| AXES_LOCKOUT_CALLABLE | None | A callable or a string path to callable that takes three arguments returns a response. For example: ``def generate_lockout_response(request: HttpRequest, original_response: HttpResponse, credentials: dict) -> HttpResponse: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. If not callable is defined, then the default implementation in ``axes.helpers.get_lockout_response`` is used for determining the correct lockout response that is sent to the requesting client. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_CLIENT_IP_CALLABLE | None | A callable or a string path to callable that takes two arguments returns a response. For example: ``def get_ip(request: HttpRequest) -> str: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. If not callable is defined, then the default implementation in ``axes.helpers.get_client_ip_address`` is used. |
|
| AXES_CLIENT_IP_CALLABLE | None | A callable or a string path to callable that takes HttpRequest as an argument and returns the resolved IP as a response. For example: ``def get_ip(request: HttpRequest) -> str: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. If not callable is defined, then the default implementation in ``axes.helpers.get_client_ip_address`` is used. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_PASSWORD_FORM_FIELD | 'password' | The name of the form or credentials field that contains your users password. |
|
| AXES_PASSWORD_FORM_FIELD | 'password' | The name of the form or credentials field that contains your users password. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| 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_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_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. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
|
|
@ -81,11 +83,28 @@ The following ``settings.py`` options are available for customizing Axes behavio
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_HTTP_RESPONSE_CODE | 429 | Sets the http response code returned when ``AXES_FAILURE_LIMIT`` is reached. For example: ``AXES_HTTP_RESPONSE_CODE = 403`` |
|
| AXES_HTTP_RESPONSE_CODE | 429 | Sets the http response code returned when ``AXES_FAILURE_LIMIT`` is reached. For example: ``AXES_HTTP_RESPONSE_CODE = 403`` |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT | True | If ``True``, a failed login attempt during lockout will reset the cool off period. |
|
| AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT | True | If ``True``, any failed login attempt during lockout resets the cool-off timer to ``now() + AXES_COOLOFF_TIME``. Repeated failed attempts keep extending the lockout period. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
| AXES_LOCKOUT_PARAMETERS | ["ip_address"] | A list of parameters that Axes uses to lock out users. It can also be callable, which takes an http request or AccesAttempt object and credentials and returns a list of parameters. Each parameter can be a string (a single parameter) or a list of strings (a combined parameter). For example, if you configure ``AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]``, axes will block clients by ip and/or username and user agent combination. See :ref:`customizing-lockout-parameters` for more details. |
|
| AXES_LOCKOUT_PARAMETERS | ["ip_address"] | A list of parameters that Axes uses to lock out users. It can also be callable, which takes an http request or AccesAttempt object and credentials and returns a list of parameters. Each parameter can be a string (a single parameter) or a list of strings (a combined parameter). For example, if you configure ``AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]``, axes will block clients by ip and/or username and user agent combination. See :ref:`customizing-lockout-parameters` for more details. |
|
||||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||||
|
|
||||||
|
**Common configurations**
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
# Classic: 3 failures -> 30 min lockout
|
||||||
|
AXES_FAILURE_LIMIT = 3
|
||||||
|
AXES_COOLOFF_TIME = timedelta(minutes=30)
|
||||||
|
|
||||||
|
# Rolling window: max 5 failures in any 15-minute period
|
||||||
|
AXES_FAILURE_LIMIT = 5
|
||||||
|
AXES_COOLOFF_TIME = timedelta(minutes=15)
|
||||||
|
AXES_USE_ATTEMPT_EXPIRATION = True
|
||||||
|
|
||||||
|
# Hard lockout (manual reset only)
|
||||||
|
AXES_FAILURE_LIMIT = 5
|
||||||
|
AXES_COOLOFF_TIME = None
|
||||||
|
|
||||||
The configuration option precedences for the access attempt monitoring are:
|
The configuration option precedences for the access attempt monitoring are:
|
||||||
|
|
||||||
1. Default: only use IP address.
|
1. Default: only use IP address.
|
||||||
|
|
@ -109,6 +128,8 @@ following settings to suit your set up to correctly resolve client IP addresses:
|
||||||
* ``AXES_IPWARE_META_PRECEDENCE_ORDER``: The names of ``request.META`` attributes as a tuple of strings
|
* ``AXES_IPWARE_META_PRECEDENCE_ORDER``: The names of ``request.META`` attributes as a tuple of strings
|
||||||
to check to get the client IP address. Check the Django documentation for header naming conventions.
|
to check to get the client IP address. Check the Django documentation for header naming conventions.
|
||||||
Default: ``IPWARE_META_PRECEDENCE_ORDER`` setting if set, else ``('REMOTE_ADDR', )``
|
Default: ``IPWARE_META_PRECEDENCE_ORDER`` setting if set, else ``('REMOTE_ADDR', )``
|
||||||
|
* ``AXES_IPWARE_PROXY_ORDER``: The order in which to evaluate IP addresses from proxy headers when multiple IPs are present
|
||||||
|
in the header chain. Must be either ``"left-most"`` or ``"right-most"``. **Default:** ``"left-most"``
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
For reverse proxies or e.g. Heroku, you might also want to fetch IP addresses from a HTTP header such as ``X-Forwarded-For``. To configure this, you can fetch IPs through the ``HTTP_X_FORWARDED_FOR`` key from the ``request.META`` property which contains all the HTTP headers in Django:
|
For reverse proxies or e.g. Heroku, you might also want to fetch IP addresses from a HTTP header such as ``X-Forwarded-For``. To configure this, you can fetch IPs through the ``HTTP_X_FORWARDED_FOR`` key from the ``request.META`` property which contains all the HTTP headers in Django:
|
||||||
|
|
@ -137,6 +158,12 @@ 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, credentials, *args, **kwargs):
|
def lockout(request, response, credentials, *args, **kwargs):
|
||||||
return JsonResponse({"status": "Locked out due to too many login failures"}, status=403)
|
return JsonResponse({"status": "Locked out due to too many login failures"}, status=403)
|
||||||
|
|
||||||
``settings.py``::
|
``settings.py``::
|
||||||
|
|
@ -188,7 +188,7 @@ Example ``AXES_LOCKOUT_PARAMETERS`` configuration:
|
||||||
|
|
||||||
AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]
|
AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]
|
||||||
|
|
||||||
This way, axes will lock out users using ip_address and/or combination of username and user agent
|
This way, axes will lock out users using ip_address or combination of username and user_agent
|
||||||
|
|
||||||
Example of callable ``AXES_LOCKOUT_PARAMETERS``:
|
Example of callable ``AXES_LOCKOUT_PARAMETERS``:
|
||||||
|
|
||||||
|
|
@ -213,7 +213,7 @@ Example of callable ``AXES_LOCKOUT_PARAMETERS``:
|
||||||
|
|
||||||
AXES_LOCKOUT_PARAMETERS = "example.utils.get_lockout_parameters"
|
AXES_LOCKOUT_PARAMETERS = "example.utils.get_lockout_parameters"
|
||||||
|
|
||||||
This way, if client ip_address is localhost, axes will lockout client only by username. In other case, axes will lockout client by username and/or ip_address.
|
This way, if client ip_address is localhost, axes will lockout client only by username. In other case, axes will lockout client by username or ip_address.
|
||||||
|
|
||||||
Customizing client ip address lookups
|
Customizing client ip address lookups
|
||||||
-------------------------------------
|
-------------------------------------
|
||||||
|
|
|
||||||
9
docs/_static/css/custom_theme.css
vendored
Normal file
9
docs/_static/css/custom_theme.css
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
@import url("theme.css");
|
||||||
|
|
||||||
|
.wy-nav-content {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wy-table-responsive table td, .wy-table-responsive table th {
|
||||||
|
white-space: inherit;
|
||||||
|
}
|
||||||
12
docs/conf.py
12
docs/conf.py
|
|
@ -6,8 +6,8 @@ More information on the configuration options is available at:
|
||||||
https://www.sphinx-doc.org/en/master/usage/configuration.html
|
https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sphinx_rtd_theme
|
# import sphinx_rtd_theme
|
||||||
from pkg_resources import get_distribution
|
from importlib.metadata import version as get_version
|
||||||
|
|
||||||
import django
|
import django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
@ -25,7 +25,7 @@ description = ("Keep track of failed login attempts in Django-powered sites.",)
|
||||||
|
|
||||||
# Add any Sphinx extension module names here, as strings.
|
# Add any Sphinx extension module names here, as strings.
|
||||||
# They can be extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
# They can be extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
||||||
extensions = ["sphinx.ext.autodoc"]
|
extensions = ["sphinx_rtd_theme","sphinx.ext.autodoc"]
|
||||||
|
|
||||||
# Add any paths that contain templates here, relative to this directory.
|
# Add any paths that contain templates here, relative to this directory.
|
||||||
templates_path = ["_templates"]
|
templates_path = ["_templates"]
|
||||||
|
|
@ -43,7 +43,7 @@ copyright = "2016, Jazzband"
|
||||||
author = "Jazzband"
|
author = "Jazzband"
|
||||||
|
|
||||||
# The full version, including alpha/beta/rc tags.
|
# The full version, including alpha/beta/rc tags.
|
||||||
release = get_distribution("django-axes").version
|
release = get_version("django-axes")
|
||||||
|
|
||||||
# The short X.Y version.
|
# The short X.Y version.
|
||||||
version = ".".join(release.split(".")[:2])
|
version = ".".join(release.split(".")[:2])
|
||||||
|
|
@ -71,8 +71,10 @@ todo_include_todos = False
|
||||||
# a list of builtin themes.
|
# a list of builtin themes.
|
||||||
html_theme = "sphinx_rtd_theme"
|
html_theme = "sphinx_rtd_theme"
|
||||||
|
|
||||||
|
html_style = "css/custom_theme.css"
|
||||||
|
|
||||||
# Add any paths that contain custom themes here, relative to this directory.
|
# Add any paths that contain custom themes here, relative to this directory.
|
||||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
# html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||||
|
|
||||||
# Add any paths that contain custom static files (such as style sheets) here,
|
# Add any paths that contain custom static files (such as style sheets) here,
|
||||||
# relative to this directory. They are copied after the builtin static files,
|
# relative to this directory. They are copied after the builtin static files,
|
||||||
|
|
|
||||||
2
mypy.ini
2
mypy.ini
|
|
@ -1,5 +1,5 @@
|
||||||
[mypy]
|
[mypy]
|
||||||
python_version = 3.8
|
python_version = 3.14
|
||||||
ignore_missing_imports = True
|
ignore_missing_imports = True
|
||||||
|
|
||||||
[mypy-axes.migrations.*]
|
[mypy-axes.migrations.*]
|
||||||
|
|
|
||||||
|
|
@ -10,35 +10,35 @@ DJANGO_SETTINGS_MODULE = "tests.settings"
|
||||||
legacy_tox_ini = """
|
legacy_tox_ini = """
|
||||||
[tox]
|
[tox]
|
||||||
envlist =
|
envlist =
|
||||||
py{38,39,310,py38}-dj32
|
py{310,311,312}-dj42
|
||||||
py{38,39,310,311,py38}-dj41
|
py{310,311,312,313}-dj52
|
||||||
py{38,39,310,311,py38}-dj42
|
py{312,313,314}-dj60
|
||||||
py311-djmain
|
py314-djmain
|
||||||
py311-djqa
|
py314-djqa
|
||||||
|
|
||||||
[gh-actions]
|
[gh-actions]
|
||||||
python =
|
python =
|
||||||
3.8: py38
|
|
||||||
3.9: py39
|
|
||||||
3.10: py310
|
3.10: py310
|
||||||
3.11: py311
|
3.11: py311
|
||||||
pypy-3.8: pypy38
|
3.12: py312
|
||||||
|
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-test.txt
|
-r requirements.txt
|
||||||
dj32: django>=3.2,<3.3
|
dj42: django>=4.2,<4.3
|
||||||
dj41: django>=4.1,<4.2
|
dj52: django>=5.2,<5.3
|
||||||
dj42: django>=4.1,<4.2
|
dj60: django>=6.0,<6.1
|
||||||
djmain: https://github.com/django/django/archive/main.tar.gz
|
djmain: https://github.com/django/django/archive/main.tar.gz
|
||||||
usedevelop = true
|
usedevelop = true
|
||||||
commands = pytest
|
commands = pytest
|
||||||
|
|
@ -47,16 +47,15 @@ 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:py311-djqa]
|
[testenv:py314-djqa]
|
||||||
deps = -r requirements-qa.txt
|
stoponfail = false
|
||||||
|
deps = -r requirements.txt
|
||||||
commands =
|
commands =
|
||||||
mypy axes
|
mypy axes
|
||||||
prospector
|
prospector axes
|
||||||
black -t py38 --check --diff axes
|
black --check --diff axes
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
black==23.3.0
|
|
||||||
mypy==1.4.1
|
|
||||||
prospector==1.10.2
|
|
||||||
types-pkg_resources # Type stub
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
-e .
|
|
||||||
django-ipware>=3
|
|
||||||
coverage==7.2.7
|
|
||||||
pytest==7.4.0
|
|
||||||
pytest-cov==4.1.0
|
|
||||||
pytest-django==4.5.2
|
|
||||||
pytest-subtests==0.11.0
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
-e .
|
-e .
|
||||||
-r requirements-qa.txt
|
black==26.3.1
|
||||||
-r requirements-test.txt
|
coverage==7.13.5
|
||||||
sphinx_rtd_theme==1.2.2
|
django-ipware>=3
|
||||||
tox==4.6.3
|
mypy==1.19.1
|
||||||
|
prospector==1.18.0
|
||||||
|
pytest-cov==7.0.0
|
||||||
|
pytest-django==4.12.0
|
||||||
|
pytest-subtests==0.15.0
|
||||||
|
pytest==9.0.2
|
||||||
|
sphinx_rtd_theme==3.1.0
|
||||||
|
tox==4.50.1
|
||||||
|
|
|
||||||
17
setup.py
17
setup.py
|
|
@ -35,8 +35,11 @@ setup(
|
||||||
package_dir={"axes": "axes"},
|
package_dir={"axes": "axes"},
|
||||||
use_scm_version=True,
|
use_scm_version=True,
|
||||||
setup_requires=["setuptools_scm"],
|
setup_requires=["setuptools_scm"],
|
||||||
python_requires=">=3.7",
|
python_requires=">=3.10",
|
||||||
install_requires=["django>=3.2", "setuptools"],
|
install_requires=[
|
||||||
|
"django>=4.2",
|
||||||
|
"asgiref>=3.6.0",
|
||||||
|
],
|
||||||
extras_require={
|
extras_require={
|
||||||
"ipware": "django-ipware>=3",
|
"ipware": "django-ipware>=3",
|
||||||
},
|
},
|
||||||
|
|
@ -47,21 +50,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,6 +3,7 @@ from string import ascii_letters, digits
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.base_user import AbstractBaseUser
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
|
||||||
|
|
@ -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
|
from django.test import override_settings, RequestFactory
|
||||||
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,12 +15,13 @@ 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):
|
||||||
attempt_time = timestamp
|
request.axes_attempt_time = timestamp
|
||||||
threshold_now = get_cool_off_threshold(attempt_time)
|
threshold_now = get_cool_off_threshold(request)
|
||||||
|
|
||||||
attempt_time = None
|
request.axes_attempt_time = None
|
||||||
threshold_none = get_cool_off_threshold(attempt_time)
|
threshold_none = get_cool_off_threshold(request)
|
||||||
|
|
||||||
self.assertEqual(threshold_now, threshold_none)
|
self.assertEqual(threshold_now, threshold_none)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,3 +129,24 @@ class ConfCheckTestCase(AxesTestCase):
|
||||||
def test_valid_callable(self):
|
def test_valid_callable(self):
|
||||||
warnings = run_checks()
|
warnings = run_checks()
|
||||||
self.assertEqual(warnings, [])
|
self.assertEqual(warnings, [])
|
||||||
|
|
||||||
|
def test_missing_settings_no_error(self):
|
||||||
|
warnings = run_checks()
|
||||||
|
self.assertEqual(warnings, [])
|
||||||
|
|
||||||
|
|
||||||
|
class LockoutParametersCheckTestCase(AxesTestCase):
|
||||||
|
@override_settings(AXES_LOCKOUT_PARAMETERS=["ip_address", "username"])
|
||||||
|
def test_valid_configuration(self):
|
||||||
|
warnings = run_checks()
|
||||||
|
self.assertEqual(warnings, [])
|
||||||
|
|
||||||
|
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "user_agent"])
|
||||||
|
def test_invalid_configuration(self):
|
||||||
|
warnings = run_checks()
|
||||||
|
warning = Warning(
|
||||||
|
msg=Messages.LOCKOUT_PARAMETERS_INVALID,
|
||||||
|
hint=Hints.LOCKOUT_PARAMETERS_INVALID,
|
||||||
|
id=Codes.LOCKOUT_PARAMETERS_INVALID,
|
||||||
|
)
|
||||||
|
self.assertEqual(warnings, [warning])
|
||||||
|
|
|
||||||
45
tests/test_conf.py
Normal file
45
tests/test_conf.py
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils.functional import SimpleLazyObject
|
||||||
|
|
||||||
|
|
||||||
|
class ConfTestCase(TestCase):
|
||||||
|
def test_axes_username_form_field_uses_lazy_evaluation(self):
|
||||||
|
"""
|
||||||
|
Test that AXES_USERNAME_FORM_FIELD uses SimpleLazyObject for lazy evaluation.
|
||||||
|
This prevents circular import issues with custom user models (issue #1280).
|
||||||
|
"""
|
||||||
|
from axes.conf import settings
|
||||||
|
|
||||||
|
# Verify that AXES_USERNAME_FORM_FIELD is a SimpleLazyObject if not overridden
|
||||||
|
# This is only the case when the setting is not explicitly defined
|
||||||
|
username_field = settings.AXES_USERNAME_FORM_FIELD
|
||||||
|
|
||||||
|
# The actual type depends on whether AXES_USERNAME_FORM_FIELD was overridden
|
||||||
|
# If it's using the default, it should be a SimpleLazyObject
|
||||||
|
# If overridden in settings, it could be a plain string
|
||||||
|
# Either way, it should be usable as a string
|
||||||
|
|
||||||
|
# Force evaluation and verify it works
|
||||||
|
username_field_str = str(username_field)
|
||||||
|
|
||||||
|
# Should get the default USERNAME_FIELD from the user model
|
||||||
|
# For the test suite, this is "username"
|
||||||
|
self.assertIsInstance(username_field_str, str)
|
||||||
|
self.assertTrue(len(username_field_str) > 0)
|
||||||
|
|
||||||
|
def test_axes_username_form_field_evaluates_correctly(self):
|
||||||
|
"""
|
||||||
|
Test that when AXES_USERNAME_FORM_FIELD is accessed, it correctly
|
||||||
|
resolves to the user model's USERNAME_FIELD.
|
||||||
|
"""
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from axes.conf import settings
|
||||||
|
|
||||||
|
# Get the expected value
|
||||||
|
expected_username_field = get_user_model().USERNAME_FIELD
|
||||||
|
|
||||||
|
# Get the actual value from axes settings
|
||||||
|
actual_username_field = str(settings.AXES_USERNAME_FORM_FIELD)
|
||||||
|
|
||||||
|
# They should match
|
||||||
|
self.assertEqual(actual_username_field, expected_username_field)
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
from platform import python_implementation
|
from platform import python_implementation
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
from datetime import datetime, timezone as dt_timezone
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from axes.handlers.database import AxesDatabaseHandler
|
||||||
|
from axes.models import AccessAttempt, AccessLog, AccessFailureLog, AccessAttemptExpiration
|
||||||
|
|
||||||
from pytest import mark
|
from pytest import mark
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.test import override_settings
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
|
||||||
from django.utils.timezone import timedelta
|
from django.utils.timezone import timedelta
|
||||||
|
|
||||||
from axes.conf import settings
|
from axes.conf import settings
|
||||||
from axes.handlers.proxy import AxesProxyHandler
|
from axes.handlers.proxy import AxesProxyHandler
|
||||||
from axes.helpers import get_client_str
|
from axes.helpers import get_client_str
|
||||||
from axes.models import AccessAttempt, AccessLog, AccessFailureLog
|
|
||||||
from tests.base import AxesTestCase
|
from tests.base import AxesTestCase
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -236,11 +238,6 @@ 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()
|
||||||
|
|
@ -572,3 +569,170 @@ class AxesTestHandlerTestCase(AxesHandlerBaseTestCase):
|
||||||
|
|
||||||
def test_handler_get_failures(self):
|
def test_handler_get_failures(self):
|
||||||
self.assertEqual(0, AxesProxyHandler.get_failures(self.request, {}))
|
self.assertEqual(0, AxesProxyHandler.get_failures(self.request, {}))
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(AXES_HANDLER="axes.handlers.database.AxesDatabaseHandler", AXES_COOLOFF_TIME=timezone.timedelta(seconds=10))
|
||||||
|
class AxesDatabaseHandlerExpirationFlagTestCase(AxesTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.handler = AxesDatabaseHandler()
|
||||||
|
self.mock_request = MagicMock()
|
||||||
|
self.mock_credentials = None
|
||||||
|
|
||||||
|
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
|
||||||
|
@patch("axes.handlers.database.log")
|
||||||
|
@patch("axes.models.AccessAttempt.objects.filter")
|
||||||
|
@patch("django.utils.timezone.now")
|
||||||
|
def test_clean_expired_user_attempts_expiration_true(self, mock_now, mock_filter, mock_log):
|
||||||
|
mock_now.return_value = datetime(2025, 1, 1, tzinfo=dt_timezone.utc)
|
||||||
|
mock_qs = MagicMock()
|
||||||
|
mock_filter.return_value = mock_qs
|
||||||
|
mock_qs.delete.return_value = (3, None)
|
||||||
|
|
||||||
|
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
||||||
|
mock_filter.assert_called_once_with(expiration__expires_at__lte=mock_now.return_value)
|
||||||
|
mock_qs.delete.assert_called_once()
|
||||||
|
mock_log.info.assert_called_with(
|
||||||
|
"AXES: Cleaned up %s expired access attempts from database that expiry were older than %s",
|
||||||
|
3,
|
||||||
|
mock_now.return_value,
|
||||||
|
)
|
||||||
|
self.assertEqual(count, 3)
|
||||||
|
|
||||||
|
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
|
||||||
|
@patch("axes.handlers.database.log")
|
||||||
|
def test_clean_expired_user_attempts_expiration_true_with_complete_deletion(self, mock_log):
|
||||||
|
AccessAttempt.objects.all().delete()
|
||||||
|
dummy_attempt = AccessAttempt.objects.create(
|
||||||
|
username="test_user",
|
||||||
|
ip_address="192.168.1.1",
|
||||||
|
failures_since_start=1,
|
||||||
|
user_agent="test_agent",
|
||||||
|
)
|
||||||
|
dummy_attempt.expiration = AccessAttemptExpiration.objects.create(
|
||||||
|
access_attempt=dummy_attempt,
|
||||||
|
expires_at=timezone.now() - timezone.timedelta(days=1) # Set to expire in the past
|
||||||
|
)
|
||||||
|
|
||||||
|
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
||||||
|
mock_log.info.assert_called_once()
|
||||||
|
|
||||||
|
# comparing count=2, as one is the dummy attempt and one is the expiration
|
||||||
|
self.assertEqual(count, 2)
|
||||||
|
self.assertEqual(
|
||||||
|
AccessAttempt.objects.count(), 0
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
AccessAttemptExpiration.objects.count(), 0
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
|
||||||
|
@patch("axes.handlers.database.log")
|
||||||
|
def test_clean_expired_user_attempts_expiration_true_with_partial_deletion(self, mock_log):
|
||||||
|
|
||||||
|
attempt_not_expired = AccessAttempt.objects.create(
|
||||||
|
username="test_user",
|
||||||
|
ip_address="192.168.1.1",
|
||||||
|
failures_since_start=1,
|
||||||
|
user_agent="test_agent",
|
||||||
|
)
|
||||||
|
attempt_not_expired.expiration = AccessAttemptExpiration.objects.create(
|
||||||
|
access_attempt=attempt_not_expired,
|
||||||
|
expires_at=timezone.now() + timezone.timedelta(days=1) # Set to expire in the future
|
||||||
|
)
|
||||||
|
|
||||||
|
attempt_expired = AccessAttempt.objects.create(
|
||||||
|
username="test_user_2",
|
||||||
|
ip_address="192.168.1.2",
|
||||||
|
failures_since_start=1,
|
||||||
|
user_agent="test_agent",
|
||||||
|
)
|
||||||
|
attempt_expired.expiration = AccessAttemptExpiration.objects.create(
|
||||||
|
access_attempt=attempt_expired,
|
||||||
|
expires_at=timezone.now() - timezone.timedelta(days=1) # Set to expire in the past
|
||||||
|
)
|
||||||
|
|
||||||
|
access_attempt_count = AccessAttempt.objects.count()
|
||||||
|
access_attempt_expiration_count = AccessAttemptExpiration.objects.count()
|
||||||
|
|
||||||
|
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
||||||
|
mock_log.info.assert_called_once()
|
||||||
|
|
||||||
|
# comparing count=2, as one is the dummy attempt and one is the expiration
|
||||||
|
self.assertEqual(count, 2)
|
||||||
|
self.assertEqual(
|
||||||
|
AccessAttempt.objects.count(), access_attempt_count - 1
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
AccessAttemptExpiration.objects.count(), access_attempt_expiration_count - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=True)
|
||||||
|
@patch("axes.handlers.database.log")
|
||||||
|
def test_clean_expired_user_attempts_expiration_true_with_no_deletion(self, mock_log):
|
||||||
|
|
||||||
|
attempt_not_expired_1 = AccessAttempt.objects.create(
|
||||||
|
username="test_user",
|
||||||
|
ip_address="192.168.1.1",
|
||||||
|
failures_since_start=1,
|
||||||
|
user_agent="test_agent",
|
||||||
|
)
|
||||||
|
attempt_not_expired_1.expiration = AccessAttemptExpiration.objects.create(
|
||||||
|
access_attempt=attempt_not_expired_1,
|
||||||
|
expires_at=timezone.now() + timezone.timedelta(days=1) # Set to expire in the future
|
||||||
|
)
|
||||||
|
|
||||||
|
attempt_not_expired_2 = AccessAttempt.objects.create(
|
||||||
|
username="test_user_2",
|
||||||
|
ip_address="192.168.1.2",
|
||||||
|
failures_since_start=1,
|
||||||
|
user_agent="test_agent",
|
||||||
|
)
|
||||||
|
attempt_not_expired_2.expiration = AccessAttemptExpiration.objects.create(
|
||||||
|
access_attempt=attempt_not_expired_2,
|
||||||
|
expires_at=timezone.now() + timezone.timedelta(days=2) # Set to expire in the future
|
||||||
|
)
|
||||||
|
|
||||||
|
access_attempt_count = AccessAttempt.objects.count()
|
||||||
|
access_attempt_expiration_count = AccessAttemptExpiration.objects.count()
|
||||||
|
|
||||||
|
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
||||||
|
mock_log.info.assert_called_once()
|
||||||
|
|
||||||
|
# comparing count=2, as one is the dummy attempt and one is the expiration
|
||||||
|
self.assertEqual(count, 0)
|
||||||
|
self.assertEqual(
|
||||||
|
AccessAttempt.objects.count(), access_attempt_count
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
AccessAttemptExpiration.objects.count(), access_attempt_expiration_count
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(AXES_USE_ATTEMPT_EXPIRATION=False)
|
||||||
|
@patch("axes.handlers.database.log")
|
||||||
|
@patch("axes.handlers.database.get_cool_off_threshold")
|
||||||
|
@patch("axes.models.AccessAttempt.objects.filter")
|
||||||
|
def test_clean_expired_user_attempts_expiration_false(self, mock_filter, mock_get_threshold, mock_log):
|
||||||
|
mock_get_threshold.return_value = "fake-threshold"
|
||||||
|
mock_qs = MagicMock()
|
||||||
|
mock_filter.return_value = mock_qs
|
||||||
|
mock_qs.delete.return_value = (2, None)
|
||||||
|
|
||||||
|
count = self.handler.clean_expired_user_attempts(request=self.mock_request, credentials=None)
|
||||||
|
mock_filter.assert_called_once_with(attempt_time__lte="fake-threshold")
|
||||||
|
mock_qs.delete.assert_called_once()
|
||||||
|
mock_log.info.assert_called_with(
|
||||||
|
"AXES: Cleaned up %s expired access attempts from database that were older than %s",
|
||||||
|
2,
|
||||||
|
"fake-threshold",
|
||||||
|
)
|
||||||
|
self.assertEqual(count, 2)
|
||||||
|
|
||||||
|
@override_settings(AXES_COOLOFF_TIME=None)
|
||||||
|
@patch("axes.handlers.database.log")
|
||||||
|
def test_clean_expired_user_attempts_no_cooloff(self, mock_log):
|
||||||
|
count = self.handler.clean_expired_user_attempts(request=None, credentials=None)
|
||||||
|
mock_log.debug.assert_called_with(
|
||||||
|
"AXES: Skipping clean for expired access attempts because no AXES_COOLOFF_TIME is configured"
|
||||||
|
)
|
||||||
|
self.assertEqual(count, 0)
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,38 @@ 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):
|
||||||
|
|
@ -82,6 +114,7 @@ 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):
|
||||||
|
|
@ -914,7 +947,7 @@ class LockoutResponseTestCase(AxesTestCase):
|
||||||
self.assertEqual(type(response), HttpResponse)
|
self.assertEqual(type(response), HttpResponse)
|
||||||
|
|
||||||
|
|
||||||
def mock_get_cool_off_str():
|
def mock_get_cool_off_str(req):
|
||||||
return timedelta(seconds=30)
|
return timedelta(seconds=30)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -928,18 +961,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_int(self):
|
def test_get_cool_off_float(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_int(self):
|
def test_get_cool_off_float_lt_0(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_int(self):
|
def test_get_cool_off_float_gt_0(self):
|
||||||
self.assertEqual(get_cool_off(), timedelta(seconds=6120))
|
self.assertEqual(get_cool_off(), timedelta(seconds=6120))
|
||||||
|
|
||||||
@override_settings(AXES_COOLOFF_TIME=lambda: timedelta(seconds=30))
|
@override_settings(AXES_COOLOFF_TIME=lambda r: 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))
|
||||||
|
|
||||||
|
|
@ -980,9 +1013,16 @@ def mock_get_lockout_response(request, credentials):
|
||||||
return HttpResponse(status=400)
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
|
||||||
|
def mock_get_lockout_response_with_original_response_param(
|
||||||
|
request, response, credentials
|
||||||
|
):
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
|
||||||
class AxesLockoutTestCase(AxesTestCase):
|
class AxesLockoutTestCase(AxesTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.request = HttpRequest()
|
self.request = HttpRequest()
|
||||||
|
self.response = HttpResponse()
|
||||||
self.credentials = dict()
|
self.credentials = dict()
|
||||||
|
|
||||||
def test_get_lockout_response(self):
|
def test_get_lockout_response(self):
|
||||||
|
|
@ -1006,6 +1046,20 @@ class AxesLockoutTestCase(AxesTestCase):
|
||||||
response = get_lockout_response(self.request, self.credentials)
|
response = get_lockout_response(self.request, self.credentials)
|
||||||
self.assertEqual(400, response.status_code)
|
self.assertEqual(400, response.status_code)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
AXES_LOCKOUT_CALLABLE=mock_get_lockout_response_with_original_response_param
|
||||||
|
)
|
||||||
|
def test_get_lockout_response_override_callable_with_original_response_param(self):
|
||||||
|
response = get_lockout_response(self.request, self.response, self.credentials)
|
||||||
|
self.assertEqual(400, response.status_code)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
AXES_LOCKOUT_CALLABLE="tests.test_helpers.mock_get_lockout_response_with_original_response_param"
|
||||||
|
)
|
||||||
|
def test_get_lockout_response_override_path_with_original_response_param(self):
|
||||||
|
response = get_lockout_response(self.request, self.response, self.credentials)
|
||||||
|
self.assertEqual(400, response.status_code)
|
||||||
|
|
||||||
@override_settings(AXES_LOCKOUT_CALLABLE=42)
|
@override_settings(AXES_LOCKOUT_CALLABLE=42)
|
||||||
def test_get_lockout_response_override_invalid(self):
|
def test_get_lockout_response_override_invalid(self):
|
||||||
with self.assertRaises(TypeError):
|
with self.assertRaises(TypeError):
|
||||||
|
|
@ -1020,6 +1074,7 @@ 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"])
|
||||||
|
|
@ -1041,6 +1096,7 @@ 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,15 +1,14 @@
|
||||||
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 = get_distribution("django-axes").version
|
_VERSION = __version__
|
||||||
|
|
||||||
|
|
||||||
@patch("axes.apps.AppConfig.initialized", False)
|
@patch("axes.apps.AppConfig.initialized", False)
|
||||||
|
|
@ -59,16 +58,21 @@ 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.
|
Test a valid logout and make sure the logout_time is updated only for that.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.login(is_valid_username=True, is_valid_password=True)
|
self.login(is_valid_username=True, is_valid_password=True)
|
||||||
self.assertIsNone(AccessLog.objects.latest("id").logout_time)
|
latest_log = AccessLog.objects.latest("id")
|
||||||
|
self.assertIsNone(latest_log.logout_time)
|
||||||
|
other_log = self.create_log(session_hash='not-the-session')
|
||||||
|
self.assertIsNone(other_log.logout_time)
|
||||||
|
|
||||||
response = self.client.post(reverse("admin:logout"))
|
response = self.logout()
|
||||||
self.assertContains(response, "Logged out")
|
self.assertContains(response, "Logged out")
|
||||||
|
other_log.refresh_from_db()
|
||||||
self.assertIsNotNone(AccessLog.objects.latest("id").logout_time)
|
self.assertIsNone(other_log.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):
|
||||||
|
|
@ -77,7 +81,7 @@ class AccessLogTestCase(AxesTestCase):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# An impossibly large post dict
|
# An impossibly large post dict
|
||||||
extra_data = {"a" * x: x for x in range(1024)}
|
extra_data = {"too-large-field": "x" * 2 ** 16}
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -86,7 +90,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.client.post(reverse("admin:logout"))
|
response = self.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)
|
||||||
|
|
@ -109,7 +113,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.client.post(reverse("admin:logout"))
|
response = self.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)
|
||||||
|
|
|
||||||
|
|
@ -56,18 +56,22 @@ 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\n"
|
expected = "10.0.0.1\tjane.doe\t4\n10.0.0.2\tjohn.doe\t15\n10.0.0.4\trichard.doe\t12\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 = "2 attempts removed.\n"
|
expected = "3 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):
|
||||||
|
|
@ -87,6 +91,13 @@ 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