mirror of
https://github.com/jazzband/django-axes.git
synced 2026-05-10 08:35:01 +00:00
Merge branch 'master' into fork-5.27.0
This commit is contained in:
commit
86a590a8dc
85 changed files with 2766 additions and 667 deletions
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve django-axes
|
||||
title: 'BUG: Short description of the problem'
|
||||
labels: 'bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
4.
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Your environment**
|
||||
python version:
|
||||
django version:
|
||||
django-axes version:
|
||||
Operating system:
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
**Possible implementation**
|
||||
Not obligatory, but suggest an idea for implementing addition or change
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for django-axes
|
||||
title: 'FEATURE REQUEST: Short description of requested feature'
|
||||
labels: 'feature request'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# What does this PR do?
|
||||
|
||||
<!--
|
||||
Congratulations! You've made it this far! You're not quite done yet though.
|
||||
|
||||
Please replace this with a description of the change and which issue is fixed (if applicable). Please also include relevant motivation and context. List any dependencies (if any) that are required for this change.
|
||||
|
||||
Once you're done, someone will review your PR shortly. They may suggest changes to make the code even better.
|
||||
-->
|
||||
|
||||
<!-- Remove if not applicable -->
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
|
||||
## Before submitting
|
||||
- [ ] This PR fixes a typo or improves the docs (you can dismiss the other checks if that's the case).
|
||||
- [ ] Did you make sure to update the documentation with your changes?
|
||||
- [ ] Did you write any new necessary tests?
|
||||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
|
|
@ -6,3 +6,9 @@ updates:
|
|||
interval: "daily"
|
||||
time: "12:00"
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "12:00"
|
||||
open-pull-requests-limit: 10
|
||||
|
|
|
|||
43
.github/workflows/codeql.yml
vendored
Normal file
43
.github/workflows/codeql.yml
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
name: "Code Scanning - Action"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
CodeQL-Build:
|
||||
# CodeQL runs on ubuntu-latest, windows-latest, and macos-latest
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
# Override language selection by uncommenting this and choosing your languages
|
||||
# with:
|
||||
# languages: go, javascript, csharp, python, cpp, 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).
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# ℹ️ 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
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following
|
||||
# three lines and modify them (or add more) to build your code if your
|
||||
# project uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
|
|
@ -5,18 +5,21 @@ on:
|
|||
tags:
|
||||
- '*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.repository == 'jazzband/django-axes'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.8
|
||||
|
||||
|
|
@ -33,8 +36,8 @@ jobs:
|
|||
|
||||
- name: Upload packages to Jazzband
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
uses: pypa/gh-action-pypi-publish@master
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
user: jazzband
|
||||
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
||||
repository_url: https://jazzband.co/projects/django-axes/upload
|
||||
repository-url: https://jazzband.co/projects/django-axes/upload
|
||||
|
|
|
|||
54
.github/workflows/test.yml
vendored
54
.github/workflows/test.yml
vendored
|
|
@ -2,40 +2,54 @@ name: Test
|
|||
|
||||
on: [push, pull_request]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 5
|
||||
matrix:
|
||||
python-version: ['3.6', '3.7', '3.8', '3.9', 'pypy3']
|
||||
django-version: ['2.2', '3.1', '3.2']
|
||||
# Tox configuration for QA environment
|
||||
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
|
||||
django-version: ['3.2', '4.2', '5.0']
|
||||
include:
|
||||
- python-version: '3.8'
|
||||
# Tox configuration for QA environment
|
||||
- python-version: '3.12'
|
||||
django-version: 'qa'
|
||||
# Django >= 3.2 only supports >= Python 3.8
|
||||
- python-version: '3.10'
|
||||
# Django main
|
||||
- python-version: '3.12'
|
||||
django-version: 'main'
|
||||
experimental: true
|
||||
# PyPy 3.10
|
||||
- python-version: 'pypy-3.10'
|
||||
django-version: '3.2'
|
||||
experimental: true
|
||||
- python-version: 'pypy-3.10'
|
||||
django-version: '4.2'
|
||||
experimental: true
|
||||
- python-version: 'pypy-3.10'
|
||||
django-version: '5.0'
|
||||
experimental: true
|
||||
exclude:
|
||||
- python-version: '3.11'
|
||||
django-version: '3.2'
|
||||
- python-version: '3.12'
|
||||
django-version: '3.2'
|
||||
- python-version: '3.8'
|
||||
django-version: 'main'
|
||||
experimental: true
|
||||
django-version: '5.0'
|
||||
- python-version: '3.9'
|
||||
django-version: 'main'
|
||||
experimental: true
|
||||
- python-version: '3.10'
|
||||
django-version: 'main'
|
||||
experimental: true
|
||||
- python-version: 'pypy3'
|
||||
django-version: 'main'
|
||||
experimental: true
|
||||
django-version: '5.0'
|
||||
|
||||
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
|
|
@ -45,7 +59,7 @@ jobs:
|
|||
echo "::set-output name=dir::$(pip cache dir)"
|
||||
|
||||
- name: Cache
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pip-cache.outputs.dir }}
|
||||
key:
|
||||
|
|
@ -65,6 +79,6 @@ jobs:
|
|||
DJANGO: ${{ matrix.django-version }}
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v1
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
name: Python ${{ matrix.python-version }}
|
||||
|
|
|
|||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -16,4 +16,11 @@ docs/_build
|
|||
test.db
|
||||
.eggs
|
||||
pip-wheel-metadata
|
||||
.vscode/
|
||||
.vscode/
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
|
|
|||
|
|
@ -2,6 +2,14 @@ ignore-paths:
|
|||
- docs
|
||||
- axes/migrations
|
||||
|
||||
pep8:
|
||||
pycodestyle:
|
||||
options:
|
||||
max-line-length: 142
|
||||
|
||||
pylint:
|
||||
disable:
|
||||
- django-not-configured
|
||||
|
||||
pyflakes:
|
||||
disable:
|
||||
- F401
|
||||
|
|
|
|||
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
|
||||
253
CHANGES.rst
253
CHANGES.rst
|
|
@ -3,6 +3,257 @@ Changes
|
|||
=======
|
||||
|
||||
|
||||
6.4.0 (2024-03-04)
|
||||
------------------
|
||||
|
||||
- Add support for Python 3.12 and Django 5.0, drop support for Django 4.1.
|
||||
[aleksihakli]
|
||||
|
||||
|
||||
6.3.1 (2024-03-04)
|
||||
------------------
|
||||
|
||||
- Drop ``setuptools`` and ``pkg_resources`` dependencies.
|
||||
[Viicos]
|
||||
|
||||
|
||||
6.3.0 (2023-12-27)
|
||||
------------------
|
||||
|
||||
- Add async support to middleware.
|
||||
[Taikono-Himazin]
|
||||
|
||||
|
||||
6.2.0 (2023-12-08)
|
||||
------------------
|
||||
|
||||
- Update documentation.
|
||||
[funkybob]
|
||||
- Add new management command ``axes_reset_ip_username``.
|
||||
[p-l-]
|
||||
- Add French translations.
|
||||
[laulaz]
|
||||
- Avoid running data migration on incorrect databases.
|
||||
[christianbundy]
|
||||
|
||||
|
||||
6.1.1 (2023-08-01)
|
||||
------------------
|
||||
|
||||
- Fix ``TransactionManagementError`` when using the database handler
|
||||
with a custom database with for ``AccessAttempt`` or ``AccessFailureLog``.
|
||||
[hirotasoshu]
|
||||
|
||||
|
||||
6.1.0 (2023-07-30)
|
||||
------------------
|
||||
|
||||
- Set ``AXES_SENSITIVE_PARAMETERS`` default value to ``["username", "ip_address"]`` in addition to the ``AXES_PASSWORD_FORM_FIELD`` configuration flag.
|
||||
This masks the username and IP address fields by default in the logs when writing information about login attempts to the application logs.
|
||||
Reverting to old configuration default of ``[]`` can be done by setting ``AXES_SENSITIVE_PARAMETERS = []`` in the Django project settings file.
|
||||
[GitRon]
|
||||
- Improve documentation on GDPR and privacy notes and configuration flags.
|
||||
[GitRon]
|
||||
|
||||
|
||||
6.0.5 (2023-07-01)
|
||||
------------------
|
||||
|
||||
- Add Indonesion translation.
|
||||
[kiraware]
|
||||
|
||||
|
||||
6.0.4 (2023-06-22)
|
||||
------------------
|
||||
|
||||
- Remove unused methods from AxesStandaloneBackend.
|
||||
[314eter]
|
||||
|
||||
|
||||
6.0.3 (2023-06-18)
|
||||
------------------
|
||||
|
||||
- Add username to admin fieldsets.
|
||||
[sevdog]
|
||||
|
||||
|
||||
6.0.2 (2023-06-13)
|
||||
------------------
|
||||
|
||||
- Add Django system checks for validating callable import path settings.
|
||||
[iafisher]
|
||||
- Improve documentation.
|
||||
[hirotasoshu]
|
||||
- Improve repository issue and PR templates.
|
||||
[hirotasoshu]
|
||||
|
||||
|
||||
6.0.1 (2023-05-17)
|
||||
------------------
|
||||
|
||||
- Fine-tune CI pipelines and RTD build requirements.
|
||||
[aleksihakli]
|
||||
|
||||
|
||||
6.0.0 (2023-05-17)
|
||||
------------------
|
||||
|
||||
Version 6 is a breaking release. Please see the documentation for upgrade instructions.
|
||||
|
||||
- Deprecate Python 3.7 support.
|
||||
[aleksihakli]
|
||||
- Deprecate ``is_admin_site`` API call with misleading naming.
|
||||
[hirotasoshu]
|
||||
- Add ``AXES_LOCKOUT_PARAMETERS`` configuration flag that will supersede ``AXES_ONLY_USER_FAILURES``, ``AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP``, ``AXES_LOCK_OUT_BY_USER_OR_IP``, and ``AXES_USE_USER_AGENT`` configurations. Add deprecation warnings for old flags. See project documentation on RTD for update instructions.
|
||||
[hirotasoshu]
|
||||
- Improve translations.
|
||||
[hirotasoshu]
|
||||
- Use Django ``cache.incr`` API for atomic cached failure counting
|
||||
[hirotasoshu, aleksihakli]
|
||||
- Make ``django-ipware`` an optional dependency. Install it with e.g. ``pip install django-axes[ipware]`` package and extras specifier. [aleksihakli]
|
||||
- Deprecate and rename old configuration flags. Old flags will be removed in or after version ``6.1``. [aleksihakli]
|
||||
* ``AXES_PROXY_ORDER`` is now ``AXES_IPWARE_PROXY_ORDER``,
|
||||
* ``AXES_PROXY_COUNT`` is now ``AXES_IPWARE_PROXY_COUNT``,
|
||||
* ``AXES_PROXY_TRUSTED_IPS`` is now ``AXES_IPWARE_PROXY_TRUSTED_IPS``, and
|
||||
* ``AXES_META_PRECEDENCE_ORDER`` is now ``AXES_IPWARE_META_PRECEDENCE_ORDER``.
|
||||
- Set 429 as the default lockout response code. [hirotasoshu]
|
||||
|
||||
|
||||
5.41.1 (2023-04-16)
|
||||
-------------------
|
||||
|
||||
- Fix sensitive parameter logging for database handler. [stereodamage]
|
||||
|
||||
5.41.0 (2023-04-02)
|
||||
-------------------
|
||||
|
||||
- Fix tests. [hirotasoshu]
|
||||
- Add ``AXES_CLIENT_CALLABLE`` setting. [hirotasoshu]
|
||||
- Update Python, Django, and package versions. [hramezani]
|
||||
|
||||
|
||||
5.40.1 (2022-11-24)
|
||||
-------------------
|
||||
|
||||
- Fix bug in user agent request blocking. [PetrDlouhy]
|
||||
|
||||
|
||||
5.40.0 (2022-11-19)
|
||||
-------------------
|
||||
|
||||
- Update packages and linters for new version support.
|
||||
[hramezani]
|
||||
- Update documentation links.
|
||||
[Arhell]
|
||||
- Use importlib instead of setuptools for Python 3.8+.
|
||||
[jedie]
|
||||
- Python 3.11 support.
|
||||
[joshuadavidthomas]
|
||||
- Documentation improvements.
|
||||
[nsht]
|
||||
- Documentation improvements.
|
||||
[timgates42]
|
||||
|
||||
|
||||
5.39.0 (2022-08-18)
|
||||
-------------------
|
||||
|
||||
- Utilize new backend class in tests to fix false negative system check warnings.
|
||||
[simonkern]
|
||||
|
||||
|
||||
5.38.0 (2022-08-16)
|
||||
-------------------
|
||||
|
||||
- Adjust changelog so release notes are correctly visible on PyPy and released package.
|
||||
[aleksihakli]
|
||||
|
||||
|
||||
5.37.0 (2022-08-16)
|
||||
-------------------
|
||||
|
||||
- Add Django 4.1 support. PyPy 3.8 has a known issue with Django 4.1 and is exempted.
|
||||
[hramezani]
|
||||
|
||||
|
||||
5.36.0 (2022-07-17)
|
||||
-------------------
|
||||
|
||||
- Add ``AxesStandaloneBackend`` without ``ModelBackend`` dependencies.
|
||||
[jcgiuffrida]
|
||||
|
||||
|
||||
5.35.0 (2022-06-01)
|
||||
-------------------
|
||||
|
||||
- Add Arabic translations.
|
||||
[YDA93]
|
||||
|
||||
|
||||
5.34.0 (2022-05-28)
|
||||
-------------------
|
||||
|
||||
- Improve German translations.
|
||||
[GitRon]
|
||||
|
||||
|
||||
5.33.0 (2022-05-16)
|
||||
-------------------
|
||||
|
||||
- Migrate MD5 cache key digests to SHA256.
|
||||
[aleksihakli]
|
||||
- Improve and streamline startup logging.
|
||||
[ShaheedHaque]
|
||||
- Improve module typing.
|
||||
[hramezani]
|
||||
- Add support for float or partial hours for ``AXES_COOLOFF_TIME``.
|
||||
[hramezani]
|
||||
|
||||
|
||||
5.32.0 (2022-04-08)
|
||||
-------------------
|
||||
|
||||
- Add support for persistent failure logging
|
||||
where failed login attempts are persisted in the database
|
||||
until a specific threshold is reached.
|
||||
[p1-gdd]
|
||||
- Add support for not resetting login times when users
|
||||
try to login during the lockout cooloff period.
|
||||
[antoine-42]
|
||||
|
||||
|
||||
5.31.0 (2022-01-08)
|
||||
-------------------
|
||||
|
||||
- Adjust version specifiers for newer Python and other package versions.
|
||||
Set package minimum Python version to 3.7.
|
||||
Relax ``django-ipware`` version requirements to allow newer versions.
|
||||
[aleksihakli]
|
||||
|
||||
|
||||
5.30.0 (2022-01-08)
|
||||
-------------------
|
||||
|
||||
- Fix package build error in 5.29.0 to allow publishing.
|
||||
[aleksihakli]
|
||||
|
||||
|
||||
5.29.0 (2022-01-08)
|
||||
-------------------
|
||||
|
||||
- Drop Python 3.6 support.
|
||||
[aleksihakli]
|
||||
|
||||
|
||||
5.28.0 (2021-12-14)
|
||||
-------------------
|
||||
|
||||
- Drop Django < 3.2 support.
|
||||
[hramezani]
|
||||
- Add Django 4.0 to test matrix.
|
||||
[hramezani]
|
||||
|
||||
|
||||
5.27.0 (2021-11-04)
|
||||
-------------------
|
||||
|
||||
|
|
@ -169,7 +420,7 @@ Changes
|
|||
- Add ``DEFAULT_AUTO_FIELD`` to test settings.
|
||||
[hramezani]
|
||||
- Fix documentation language.
|
||||
[danielquinn]
|
||||
[danielquinn]
|
||||
- Fix Python package version specifiers and remove redundant imports.
|
||||
[aleksihakli]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,30 @@
|
|||
.. _development:
|
||||
.. image:: https://jazzband.co/static/img/jazzband.svg
|
||||
:target: https://jazzband.co/
|
||||
:alt: Jazzband
|
||||
|
||||
This is a `Jazzband <https://jazzband.co>`_ project. By contributing you agree to abide by the `Contributor Code of Conduct <https://jazzband.co/about/conduct>`_ and follow the `guidelines <https://jazzband.co/about/guidelines>`_.
|
||||
|
||||
|
||||
Contributions
|
||||
=============
|
||||
|
||||
All contributions are welcome!
|
||||
|
||||
It is best to separate proposed changes and PRs into small, distinct patches
|
||||
by type so that they can be merged faster into upstream and released quicker.
|
||||
|
||||
One way to organize contributions would be to separate PRs for e.g.
|
||||
|
||||
* bugfixes,
|
||||
* new features,
|
||||
* code and design improvements,
|
||||
* documentation improvements, or
|
||||
* tooling and CI improvements.
|
||||
|
||||
Merging contributions requires passing the checks configured
|
||||
with the CI. This includes running tests and linters successfully
|
||||
on the currently officially supported Python and Django versions.
|
||||
|
||||
|
||||
Development
|
||||
===========
|
||||
|
|
@ -41,6 +67,6 @@ Tox runs the same test set that is run by GitHub Actions, and your code should b
|
|||
|
||||
If you wish to limit the testing to specific environment(s), you can parametrize the tox run::
|
||||
|
||||
$ tox -e py39-django22
|
||||
$ tox -e py39-django32
|
||||
|
||||
After you have pushed your changes, open a pull request on GitHub for getting your code upstreamed.
|
||||
29
README.rst
29
README.rst
|
|
@ -77,30 +77,7 @@ If you have questions or have trouble using the app please file a bug report at:
|
|||
https://github.com/jazzband/django-axes/issues
|
||||
|
||||
|
||||
Contributions
|
||||
-------------
|
||||
Contributing
|
||||
------------
|
||||
|
||||
All contributions are welcome!
|
||||
|
||||
It is best to separate proposed changes and PRs into small, distinct patches
|
||||
by type so that they can be merged faster into upstream and released quicker.
|
||||
|
||||
One way to organize contributions would be to separate PRs for e.g.
|
||||
|
||||
* bugfixes,
|
||||
* new features,
|
||||
* code and design improvements,
|
||||
* documentation improvements, or
|
||||
* tooling and CI improvements.
|
||||
|
||||
Merging contributions requires passing the checks configured
|
||||
with the CI. This includes running tests and linters successfully
|
||||
on the currently officially supported Python and Django versions.
|
||||
|
||||
The test automation is run automatically with GitHub Actions, but you can
|
||||
run it locally with the ``tox`` command before pushing commits.
|
||||
|
||||
Please note that this is a `Jazzband <https://jazzband.co>`_ project.
|
||||
By contributing you agree to abide by the
|
||||
`Contributor Code of Conduct <https://jazzband.co/about/conduct>`_
|
||||
and follow the `guidelines <https://jazzband.co/about/guidelines>`_.
|
||||
See `CONTRIBUTING <CONTRIBUTING.rst>`__.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
from pkg_resources import get_distribution
|
||||
from importlib.metadata import version
|
||||
|
||||
import django
|
||||
|
||||
if django.VERSION < (3, 2):
|
||||
default_app_config = "axes.apps.AppConfig"
|
||||
|
||||
__version__ = get_distribution("django-axes").version
|
||||
__version__ = version("django-axes")
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
from django.contrib import admin
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from axes.conf import settings
|
||||
from axes.models import AccessAttempt, AccessLog
|
||||
from axes.models import AccessAttempt, AccessLog, AccessFailureLog
|
||||
|
||||
|
||||
class AccessAttemptAdmin(admin.ModelAdmin):
|
||||
|
|
@ -22,7 +23,7 @@ class AccessAttemptAdmin(admin.ModelAdmin):
|
|||
date_hierarchy = "attempt_time"
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("path_info", "failures_since_start")}),
|
||||
(None, {"fields": ("username", "path_info", "failures_since_start")}),
|
||||
(_("Form Data"), {"fields": ("get_data", "post_data")}),
|
||||
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
|
||||
)
|
||||
|
|
@ -39,7 +40,7 @@ class AccessAttemptAdmin(admin.ModelAdmin):
|
|||
"failures_since_start",
|
||||
]
|
||||
|
||||
def has_add_permission(self, request):
|
||||
def has_add_permission(self, request: HttpRequest) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -60,7 +61,7 @@ class AccessLogAdmin(admin.ModelAdmin):
|
|||
date_hierarchy = "attempt_time"
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("path_info",)}),
|
||||
(None, {"fields": ("username", "path_info")}),
|
||||
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
|
||||
)
|
||||
|
||||
|
|
@ -74,10 +75,46 @@ class AccessLogAdmin(admin.ModelAdmin):
|
|||
"logout_time",
|
||||
]
|
||||
|
||||
def has_add_permission(self, request):
|
||||
def has_add_permission(self, request: HttpRequest) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class AccessFailureLogAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"attempt_time",
|
||||
"ip_address",
|
||||
"username",
|
||||
"user_agent",
|
||||
"path_info",
|
||||
"locked_out",
|
||||
)
|
||||
|
||||
list_filter = ["attempt_time", "locked_out", "path_info"]
|
||||
|
||||
search_fields = ["ip_address", "user_agent", "username", "path_info"]
|
||||
|
||||
date_hierarchy = "attempt_time"
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("username", "path_info")}),
|
||||
(_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
|
||||
)
|
||||
|
||||
readonly_fields = [
|
||||
"user_agent",
|
||||
"ip_address",
|
||||
"username",
|
||||
"http_accept",
|
||||
"path_info",
|
||||
"attempt_time",
|
||||
"locked_out",
|
||||
]
|
||||
|
||||
def has_add_permission(self, request: HttpRequest) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
if settings.AXES_ENABLE_ADMIN:
|
||||
admin.site.register(AccessAttempt, AccessAttemptAdmin)
|
||||
admin.site.register(AccessLog, AccessLogAdmin)
|
||||
admin.site.register(AccessFailureLog, AccessFailureLogAdmin)
|
||||
|
|
|
|||
41
axes/apps.py
41
axes/apps.py
|
|
@ -1,7 +1,10 @@
|
|||
# pylint: disable=import-outside-toplevel, unused-import
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from django import apps
|
||||
from pkg_resources import get_distribution
|
||||
|
||||
from axes import __version__
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
|
@ -25,25 +28,33 @@ class AppConfig(apps.AppConfig):
|
|||
cls.initialized = True
|
||||
|
||||
# Only import settings, checks, and signals one time after Django has been initialized
|
||||
from axes.conf import settings # noqa
|
||||
from axes import checks, signals # noqa
|
||||
from axes.conf import settings
|
||||
from axes import checks, signals
|
||||
|
||||
# Skip startup log messages if Axes is not set to verbose
|
||||
if settings.AXES_VERBOSE:
|
||||
log.info("AXES: BEGIN LOG")
|
||||
log.info(
|
||||
"AXES: Using django-axes version %s",
|
||||
get_distribution("django-axes").version,
|
||||
)
|
||||
if callable(settings.AXES_LOCKOUT_PARAMETERS) or isinstance(
|
||||
settings.AXES_LOCKOUT_PARAMETERS, str
|
||||
):
|
||||
mode = "blocking by parameters that are calculated in a custom callable"
|
||||
|
||||
if settings.AXES_ONLY_USER_FAILURES:
|
||||
log.info("AXES: blocking by username only.")
|
||||
elif settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP:
|
||||
log.info("AXES: blocking by combination of username and IP.")
|
||||
elif settings.AXES_LOCK_OUT_BY_USER_OR_IP:
|
||||
log.info("AXES: blocking by username or IP.")
|
||||
else:
|
||||
log.info("AXES: blocking by IP only.")
|
||||
mode = "blocking by " + " or ".join(
|
||||
[
|
||||
(
|
||||
param
|
||||
if isinstance(param, str)
|
||||
else "combination of " + " and ".join(param)
|
||||
)
|
||||
for param in settings.AXES_LOCKOUT_PARAMETERS
|
||||
]
|
||||
)
|
||||
|
||||
log.info(
|
||||
"AXES: BEGIN version %s, %s",
|
||||
__version__,
|
||||
mode,
|
||||
)
|
||||
|
||||
def ready(self):
|
||||
self.initialize()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
from logging import getLogger
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from django.db.models import QuerySet
|
||||
from django.http import HttpRequest
|
||||
from django.utils.timezone import datetime, now
|
||||
|
||||
from axes.conf import settings
|
||||
|
|
@ -11,7 +12,7 @@ from axes.models import AccessAttempt
|
|||
log = getLogger(__name__)
|
||||
|
||||
|
||||
def get_cool_off_threshold(attempt_time: datetime = None) -> datetime:
|
||||
def get_cool_off_threshold(attempt_time: Optional[datetime] = None) -> datetime:
|
||||
"""
|
||||
Get threshold for fetching access attempts from the database.
|
||||
"""
|
||||
|
|
@ -27,7 +28,9 @@ def get_cool_off_threshold(attempt_time: datetime = None) -> datetime:
|
|||
return attempt_time - cool_off
|
||||
|
||||
|
||||
def filter_user_attempts(request, credentials: dict = None) -> List[QuerySet]:
|
||||
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.
|
||||
"""
|
||||
|
|
@ -35,7 +38,7 @@ def filter_user_attempts(request, credentials: dict = None) -> List[QuerySet]:
|
|||
username = get_client_username(request, credentials)
|
||||
|
||||
filter_kwargs_list = get_client_parameters(
|
||||
username, request.axes_ip_address, request.axes_user_agent
|
||||
username, request.axes_ip_address, request.axes_user_agent, request, credentials
|
||||
)
|
||||
attempts_list = [
|
||||
AccessAttempt.objects.filter(**filter_kwargs)
|
||||
|
|
@ -44,7 +47,9 @@ def filter_user_attempts(request, credentials: dict = None) -> List[QuerySet]:
|
|||
return attempts_list
|
||||
|
||||
|
||||
def get_user_attempts(request, credentials: dict = None) -> List[QuerySet]:
|
||||
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.
|
||||
"""
|
||||
|
|
@ -62,7 +67,7 @@ def get_user_attempts(request, credentials: dict = None) -> List[QuerySet]:
|
|||
return [attempts.filter(attempt_time__gte=threshold) for attempts in attempts_list]
|
||||
|
||||
|
||||
def clean_expired_user_attempts(attempt_time: datetime = None) -> int:
|
||||
def clean_expired_user_attempts(attempt_time: Optional[datetime] = None) -> int:
|
||||
"""
|
||||
Clean expired user attempts from the database.
|
||||
"""
|
||||
|
|
@ -83,7 +88,9 @@ def clean_expired_user_attempts(attempt_time: datetime = None) -> int:
|
|||
return count
|
||||
|
||||
|
||||
def reset_user_attempts(request, credentials: dict = None) -> int:
|
||||
def reset_user_attempts(
|
||||
request: HttpRequest, credentials: Optional[dict] = None
|
||||
) -> int:
|
||||
"""
|
||||
Reset all user attempts that match the given request and credentials.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
from typing import Optional
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from django.http import HttpRequest
|
||||
|
||||
from axes.exceptions import (
|
||||
AxesBackendPermissionDenied,
|
||||
|
|
@ -8,7 +11,7 @@ from axes.handlers.proxy import AxesProxyHandler
|
|||
from axes.helpers import get_credentials, get_lockout_message, toggleable
|
||||
|
||||
|
||||
class AxesBackend(ModelBackend):
|
||||
class AxesStandaloneBackend:
|
||||
"""
|
||||
Authentication backend class that forbids login attempts for locked out users.
|
||||
|
||||
|
|
@ -16,12 +19,17 @@ class AxesBackend(ModelBackend):
|
|||
prevent locked out users from being logged in by the Django authentication flow.
|
||||
|
||||
.. note:: This backend does not log your user in. It monitors login attempts.
|
||||
It also does not run any permissions checks at all.
|
||||
Authentication is handled by the following backends that are configured in ``AUTHENTICATION_BACKENDS``.
|
||||
"""
|
||||
|
||||
@toggleable
|
||||
def authenticate(
|
||||
self, request, username: str = None, password: str = None, **kwargs: dict
|
||||
self,
|
||||
request: HttpRequest,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
**kwargs: dict,
|
||||
):
|
||||
"""
|
||||
Checks user lockout status and raises an exception if user is not allowed to log in.
|
||||
|
|
@ -51,6 +59,10 @@ class AxesBackend(ModelBackend):
|
|||
response_context = kwargs.get("response_context", {})
|
||||
response_context["error"] = error_msg
|
||||
|
||||
# This flag can be used later to check if it was Axes that denied the login attempt.
|
||||
if not settings.AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT:
|
||||
request.axes_locked_out = True
|
||||
|
||||
# Raise an error that stops the authentication flows at django.contrib.auth.authenticate.
|
||||
# This error stops bubbling up at the authenticate call which catches backend PermissionDenied errors.
|
||||
# After this error is caught by authenticate it emits a signal indicating user login failed,
|
||||
|
|
@ -60,3 +72,16 @@ class AxesBackend(ModelBackend):
|
|||
raise AxesBackendPermissionDenied(
|
||||
"AxesBackend detected that the given user is locked out"
|
||||
)
|
||||
|
||||
|
||||
class AxesBackend(AxesStandaloneBackend, ModelBackend):
|
||||
"""
|
||||
Axes authentication backend that also inherits from ModelBackend,
|
||||
and thus also performs other functions of ModelBackend such as permissions checks.
|
||||
|
||||
Use this class as the first item of ``AUTHENTICATION_BACKENDS`` to
|
||||
prevent locked out users from being logged in by the Django authentication flow.
|
||||
|
||||
.. note:: This backend does not log your user in. It monitors login attempts.
|
||||
Authentication is handled by the following backends that are configured in ``AUTHENTICATION_BACKENDS``.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from django.core.checks import ( # pylint: disable=redefined-builtin
|
|||
)
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from axes.backends import AxesBackend
|
||||
from axes.backends import AxesStandaloneBackend
|
||||
from axes.conf import settings
|
||||
|
||||
|
||||
|
|
@ -19,17 +19,17 @@ class Messages:
|
|||
MIDDLEWARE_INVALID = (
|
||||
"You do not have 'axes.middleware.AxesMiddleware' in your settings.MIDDLEWARE."
|
||||
)
|
||||
BACKEND_INVALID = "You do not have 'axes.backends.AxesBackend' 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"
|
||||
CALLABLE_INVALID = "{callable_setting} is not a valid callable."
|
||||
|
||||
|
||||
class Hints:
|
||||
CACHE_INVALID = None
|
||||
MIDDLEWARE_INVALID = None
|
||||
BACKEND_INVALID = (
|
||||
"AxesModelBackend was renamed to AxesBackend in django-axes version 5.0."
|
||||
)
|
||||
BACKEND_INVALID = "AxesModelBackend was renamed to AxesStandaloneBackend in django-axes version 5.0."
|
||||
SETTING_DEPRECATED = None
|
||||
CALLABLE_INVALID = None
|
||||
|
||||
|
||||
class Codes:
|
||||
|
|
@ -37,6 +37,7 @@ class Codes:
|
|||
MIDDLEWARE_INVALID = "axes.W002"
|
||||
BACKEND_INVALID = "axes.W003"
|
||||
SETTING_DEPRECATED = "axes.W004"
|
||||
CALLABLE_INVALID = "axes.W005"
|
||||
|
||||
|
||||
@register(Tags.security, Tags.caches, Tags.compatibility)
|
||||
|
|
@ -101,7 +102,7 @@ def axes_backend_check(app_configs, **kwargs): # pylint: disable=unused-argumen
|
|||
"Can not import backend class defined in settings.AUTHENTICATION_BACKENDS"
|
||||
) from e
|
||||
|
||||
if issubclass(backend, AxesBackend):
|
||||
if issubclass(backend, AxesStandaloneBackend):
|
||||
found = True
|
||||
break
|
||||
|
||||
|
|
@ -124,6 +125,19 @@ def axes_deprecation_check(app_configs, **kwargs): # pylint: disable=unused-arg
|
|||
deprecated_settings = [
|
||||
"AXES_DISABLE_SUCCESS_ACCESS_LOG",
|
||||
"AXES_LOGGER",
|
||||
# AXES_PROXY_ and AXES_META_ parameters were updated to more explicit
|
||||
# AXES_IPWARE_PROXY_ and AXES_IPWARE_META_ prefixes in version 6.x
|
||||
"AXES_PROXY_ORDER",
|
||||
"AXES_PROXY_COUNT",
|
||||
"AXES_PROXY_TRUSTED_IPS",
|
||||
"AXES_META_PRECEDENCE_ORDER",
|
||||
# AXES_ONLY_USER_FAILURES, AXES_USE_USER_AGENT and
|
||||
# AXES_LOCK_OUT parameters were replaced with AXES_LOCKOUT_PARAMETERS
|
||||
# in version 6.x
|
||||
"AXES_ONLY_USER_FAILURES",
|
||||
"AXES_LOCK_OUT_BY_USER_OR_IP",
|
||||
"AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP",
|
||||
"AXES_USE_USER_AGENT",
|
||||
]
|
||||
|
||||
for deprecated_setting in deprecated_settings:
|
||||
|
|
@ -142,3 +156,49 @@ def axes_deprecation_check(app_configs, **kwargs): # pylint: disable=unused-arg
|
|||
pass
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
@register
|
||||
def axes_conf_check(app_configs, **kwargs): # pylint: disable=unused-argument
|
||||
warnings = []
|
||||
|
||||
callable_settings = [
|
||||
"AXES_CLIENT_IP_CALLABLE",
|
||||
"AXES_CLIENT_STR_CALLABLE",
|
||||
"AXES_LOCKOUT_CALLABLE",
|
||||
"AXES_USERNAME_CALLABLE",
|
||||
"AXES_WHITELIST_CALLABLE",
|
||||
"AXES_COOLOFF_TIME",
|
||||
"AXES_LOCKOUT_PARAMETERS",
|
||||
]
|
||||
|
||||
for callable_setting in callable_settings:
|
||||
value = getattr(settings, callable_setting)
|
||||
if not is_valid_callable(value):
|
||||
warnings.append(
|
||||
Warning(
|
||||
msg=Messages.CALLABLE_INVALID.format(
|
||||
callable_setting=callable_setting
|
||||
),
|
||||
hint=Hints.CALLABLE_INVALID,
|
||||
id=Codes.CALLABLE_INVALID,
|
||||
)
|
||||
)
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def is_valid_callable(value) -> bool:
|
||||
if value is None:
|
||||
return True
|
||||
|
||||
if callable(value):
|
||||
return True
|
||||
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
import_string(value)
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
|
|
|||
115
axes/conf.py
115
axes/conf.py
|
|
@ -1,7 +1,6 @@
|
|||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
# disable plugin when set to False
|
||||
settings.AXES_ENABLED = getattr(settings, "AXES_ENABLED", True)
|
||||
|
||||
|
|
@ -11,18 +10,30 @@ settings.AXES_FAILURE_LIMIT = getattr(settings, "AXES_FAILURE_LIMIT", 3)
|
|||
# see if the user has set axes to lock out logins after failure limit
|
||||
settings.AXES_LOCK_OUT_AT_FAILURE = getattr(settings, "AXES_LOCK_OUT_AT_FAILURE", True)
|
||||
|
||||
# lock out with the combination of username and IP address
|
||||
settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP = getattr(
|
||||
settings, "AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP", False
|
||||
)
|
||||
# lockout parameters
|
||||
# default value will be ["ip_address"] after removing AXES_LOCK_OUT params support
|
||||
settings.AXES_LOCKOUT_PARAMETERS = getattr(settings, "AXES_LOCKOUT_PARAMETERS", None)
|
||||
|
||||
# lock out with the username or IP address
|
||||
settings.AXES_LOCK_OUT_BY_USER_OR_IP = getattr(
|
||||
settings, "AXES_LOCK_OUT_BY_USER_OR_IP", False
|
||||
)
|
||||
# TODO: remove it in future versions
|
||||
if settings.AXES_LOCKOUT_PARAMETERS is None:
|
||||
if getattr(settings, "AXES_ONLY_USER_FAILURES", False):
|
||||
settings.AXES_LOCKOUT_PARAMETERS = ["username"]
|
||||
else:
|
||||
if getattr(settings, "AXES_LOCK_OUT_BY_USER_OR_IP", False):
|
||||
settings.AXES_LOCKOUT_PARAMETERS = ["username", "ip_address"]
|
||||
elif getattr(settings, "AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP", False):
|
||||
settings.AXES_LOCKOUT_PARAMETERS = [["username", "ip_address"]]
|
||||
else:
|
||||
settings.AXES_LOCKOUT_PARAMETERS = ["ip_address"]
|
||||
|
||||
# lock out with username and never the IP or user agent
|
||||
settings.AXES_ONLY_USER_FAILURES = getattr(settings, "AXES_ONLY_USER_FAILURES", False)
|
||||
if getattr(settings, "AXES_USE_USER_AGENT", False):
|
||||
if isinstance(settings.AXES_LOCKOUT_PARAMETERS[0], str):
|
||||
settings.AXES_LOCKOUT_PARAMETERS[0] = [
|
||||
settings.AXES_LOCKOUT_PARAMETERS[0],
|
||||
"user_agent",
|
||||
]
|
||||
else:
|
||||
settings.AXES_LOCKOUT_PARAMETERS[0].append("user_agent")
|
||||
|
||||
# lock out just for admin site
|
||||
settings.AXES_ONLY_ADMIN_SITE = getattr(settings, "AXES_ONLY_ADMIN_SITE", False)
|
||||
|
|
@ -30,9 +41,6 @@ settings.AXES_ONLY_ADMIN_SITE = getattr(settings, "AXES_ONLY_ADMIN_SITE", False)
|
|||
# show Axes logs in admin
|
||||
settings.AXES_ENABLE_ADMIN = getattr(settings, "AXES_ENABLE_ADMIN", True)
|
||||
|
||||
# lock out with the user agent, has no effect when ONLY_USER_FAILURES is set
|
||||
settings.AXES_USE_USER_AGENT = getattr(settings, "AXES_USE_USER_AGENT", False)
|
||||
|
||||
# use a specific username field to retrieve from login POST data
|
||||
settings.AXES_USERNAME_FORM_FIELD = getattr(
|
||||
settings, "AXES_USERNAME_FORM_FIELD", "username"
|
||||
|
|
@ -52,11 +60,22 @@ settings.AXES_WHITELIST_CALLABLE = getattr(settings, "AXES_WHITELIST_CALLABLE",
|
|||
# return custom lockout response if configured
|
||||
settings.AXES_LOCKOUT_CALLABLE = getattr(settings, "AXES_LOCKOUT_CALLABLE", None)
|
||||
|
||||
# use a provided callable to get client ip address
|
||||
settings.AXES_CLIENT_IP_CALLABLE = getattr(settings, "AXES_CLIENT_IP_CALLABLE", None)
|
||||
|
||||
# reset the number of failed attempts after one successful attempt
|
||||
settings.AXES_RESET_ON_SUCCESS = getattr(settings, "AXES_RESET_ON_SUCCESS", False)
|
||||
|
||||
settings.AXES_DISABLE_ACCESS_LOG = getattr(settings, "AXES_DISABLE_ACCESS_LOG", False)
|
||||
|
||||
settings.AXES_ENABLE_ACCESS_FAILURE_LOG = getattr(
|
||||
settings, "AXES_ENABLE_ACCESS_FAILURE_LOG", False
|
||||
)
|
||||
|
||||
settings.AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT = getattr(
|
||||
settings, "AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT", 1000
|
||||
)
|
||||
|
||||
settings.AXES_HANDLER = getattr(
|
||||
settings, "AXES_HANDLER", "axes.handlers.database.AxesDatabaseHandler"
|
||||
)
|
||||
|
|
@ -98,24 +117,6 @@ settings.AXES_PERMALOCK_MESSAGE = getattr(
|
|||
),
|
||||
)
|
||||
|
||||
# if your deployment is using reverse proxies, set this value to 'left-most' or 'right-most' per your configuration
|
||||
settings.AXES_PROXY_ORDER = getattr(settings, "AXES_PROXY_ORDER", "left-most")
|
||||
|
||||
# if your deployment is using reverse proxies, set this value to the number of proxies in front of Django
|
||||
settings.AXES_PROXY_COUNT = getattr(settings, "AXES_PROXY_COUNT", None)
|
||||
|
||||
# if your deployment is using reverse proxies, set to your trusted proxy IP addresses prefixes if needed
|
||||
settings.AXES_PROXY_TRUSTED_IPS = getattr(settings, "AXES_PROXY_TRUSTED_IPS", None)
|
||||
|
||||
# set to the names of request.META attributes that should be checked for the IP address of the client
|
||||
# if your deployment is using reverse proxies, ensure that the header attributes are securely set by the proxy
|
||||
# ensure that the client can not spoof the headers by setting them and sending them through the proxy
|
||||
settings.AXES_META_PRECEDENCE_ORDER = getattr(
|
||||
settings,
|
||||
"AXES_META_PRECEDENCE_ORDER",
|
||||
getattr(settings, "IPWARE_META_PRECEDENCE_ORDER", ("REMOTE_ADDR",)),
|
||||
)
|
||||
|
||||
# set CORS allowed origins when calling authentication over ajax
|
||||
settings.AXES_ALLOWED_CORS_ORIGINS = getattr(settings, "AXES_ALLOWED_CORS_ORIGINS", "*")
|
||||
|
||||
|
|
@ -123,7 +124,7 @@ settings.AXES_ALLOWED_CORS_ORIGINS = getattr(settings, "AXES_ALLOWED_CORS_ORIGIN
|
|||
settings.AXES_SENSITIVE_PARAMETERS = getattr(
|
||||
settings,
|
||||
"AXES_SENSITIVE_PARAMETERS",
|
||||
[],
|
||||
["username", "ip_address"],
|
||||
)
|
||||
|
||||
# set the callable for the readable string that can be used in
|
||||
|
|
@ -131,4 +132,50 @@ settings.AXES_SENSITIVE_PARAMETERS = getattr(
|
|||
settings.AXES_CLIENT_STR_CALLABLE = getattr(settings, "AXES_CLIENT_STR_CALLABLE", None)
|
||||
|
||||
# set the HTTP response code given by too many requests
|
||||
settings.AXES_HTTP_RESPONSE_CODE = getattr(settings, "AXES_HTTP_RESPONSE_CODE", 403)
|
||||
settings.AXES_HTTP_RESPONSE_CODE = getattr(settings, "AXES_HTTP_RESPONSE_CODE", 429)
|
||||
|
||||
# If True, a failed login attempt during lockout will reset the cool off period
|
||||
settings.AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT = getattr(
|
||||
settings, "AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT", True
|
||||
)
|
||||
|
||||
|
||||
###
|
||||
# django-ipware settings for client IP address calculation and proxy detection
|
||||
# there are old AXES_PROXY_ and AXES_META_ legacy keys present for backwards compatibility
|
||||
# see https://github.com/un33k/django-ipware for further details
|
||||
###
|
||||
|
||||
# if your deployment is using reverse proxies, set this value to 'left-most' or 'right-most' per your configuration
|
||||
settings.AXES_IPWARE_PROXY_ORDER = getattr(
|
||||
settings,
|
||||
"AXES_IPWARE_PROXY_ORDER",
|
||||
getattr(settings, "AXES_PROXY_ORDER", "left-most"),
|
||||
)
|
||||
|
||||
# if your deployment is using reverse proxies, set this value to the number of proxies in front of Django
|
||||
settings.AXES_IPWARE_PROXY_COUNT = getattr(
|
||||
settings,
|
||||
"AXES_IPWARE_PROXY_COUNT",
|
||||
getattr(settings, "AXES_PROXY_COUNT", None),
|
||||
)
|
||||
|
||||
# if your deployment is using reverse proxies, set to your trusted proxy IP addresses prefixes if needed
|
||||
settings.AXES_IPWARE_PROXY_TRUSTED_IPS = getattr(
|
||||
settings,
|
||||
"AXES_IPWARE_PROXY_TRUSTED_IPS",
|
||||
getattr(settings, "AXES_PROXY_TRUSTED_IPS", None),
|
||||
)
|
||||
|
||||
# set to the names of request.META attributes that should be checked for the IP address of the client
|
||||
# if your deployment is using reverse proxies, ensure that the header attributes are securely set by the proxy
|
||||
# ensure that the client can not spoof the headers by setting them and sending them through the proxy
|
||||
settings.AXES_IPWARE_META_PRECEDENCE_ORDER = getattr(
|
||||
settings,
|
||||
"AXES_IPWARE_META_PRECEDENCE_ORDER",
|
||||
getattr(
|
||||
settings,
|
||||
"AXES_META_PRECEDENCE_ORDER",
|
||||
getattr(settings, "IPWARE_META_PRECEDENCE_ORDER", ("REMOTE_ADDR",)),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from warnings import warn
|
||||
|
||||
from django.urls import reverse
|
||||
from django.urls.exceptions import NoReverseMatch
|
||||
|
|
@ -41,7 +43,7 @@ class AbstractAxesHandler(ABC):
|
|||
raise NotImplementedError("user_logged_out should be implemented")
|
||||
|
||||
@abstractmethod
|
||||
def get_failures(self, request, credentials: dict = None) -> int:
|
||||
def get_failures(self, request, credentials: Optional[dict] = None) -> int:
|
||||
"""
|
||||
Checks the number of failures associated to the given request and credentials.
|
||||
|
||||
|
|
@ -65,7 +67,7 @@ class AxesBaseHandler: # pylint: disable=unused-argument
|
|||
.. note:: This is a virtual class and **can not be used without specialization**.
|
||||
"""
|
||||
|
||||
def is_allowed(self, request, credentials: dict = None) -> bool:
|
||||
def is_allowed(self, request, credentials: Optional[dict] = None) -> bool:
|
||||
"""
|
||||
Checks if the user is allowed to access or use given functionality such as a login view or authentication.
|
||||
|
||||
|
|
@ -80,7 +82,7 @@ class AxesBaseHandler: # pylint: disable=unused-argument
|
|||
and inspiration on some common checks and access restrictions before writing your own implementation.
|
||||
"""
|
||||
|
||||
if self.is_admin_site(request):
|
||||
if settings.AXES_ONLY_ADMIN_SITE and not self.is_admin_request(request):
|
||||
return True
|
||||
|
||||
if self.is_blacklisted(request, credentials):
|
||||
|
|
@ -94,7 +96,7 @@ class AxesBaseHandler: # pylint: disable=unused-argument
|
|||
|
||||
return True
|
||||
|
||||
def is_blacklisted(self, request, credentials: dict = None) -> bool:
|
||||
def is_blacklisted(self, request, credentials: Optional[dict] = None) -> bool:
|
||||
"""
|
||||
Checks if the request or given credentials are blacklisted from access.
|
||||
"""
|
||||
|
|
@ -104,7 +106,7 @@ class AxesBaseHandler: # pylint: disable=unused-argument
|
|||
|
||||
return False
|
||||
|
||||
def is_whitelisted(self, request, credentials: dict = None) -> bool:
|
||||
def is_whitelisted(self, request, credentials: Optional[dict] = None) -> bool:
|
||||
"""
|
||||
Checks if the request or given credentials are whitelisted for access.
|
||||
"""
|
||||
|
|
@ -120,7 +122,7 @@ class AxesBaseHandler: # pylint: disable=unused-argument
|
|||
|
||||
return False
|
||||
|
||||
def is_locked(self, request, credentials: dict = None) -> bool:
|
||||
def is_locked(self, request, credentials: Optional[dict] = None) -> bool:
|
||||
"""
|
||||
Checks if the request or given credentials are locked.
|
||||
"""
|
||||
|
|
@ -133,47 +135,103 @@ class AxesBaseHandler: # pylint: disable=unused-argument
|
|||
|
||||
return False
|
||||
|
||||
def get_admin_url(self) -> Optional[str]:
|
||||
"""
|
||||
Returns admin url if exists, otherwise returns None
|
||||
"""
|
||||
try:
|
||||
return reverse("admin:index")
|
||||
except NoReverseMatch:
|
||||
return None
|
||||
|
||||
def is_admin_request(self, request) -> bool:
|
||||
"""
|
||||
Checks that request located under admin site
|
||||
"""
|
||||
if hasattr(request, "path"):
|
||||
admin_url = self.get_admin_url()
|
||||
return (
|
||||
admin_url is not None
|
||||
and re.match(f"^{admin_url}", request.path) is not None
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
def is_admin_site(self, request) -> bool:
|
||||
"""
|
||||
Checks if the request is for admin site.
|
||||
Checks if the request is NOT for admin site
|
||||
if `settings.AXES_ONLY_ADMIN_SITE` is True.
|
||||
"""
|
||||
warn(
|
||||
(
|
||||
"This method is deprecated and will be removed in future versions. "
|
||||
"If you looking for method that checks if `request.path` located under "
|
||||
"admin site, use `is_admin_request` instead."
|
||||
),
|
||||
DeprecationWarning,
|
||||
)
|
||||
if settings.AXES_ONLY_ADMIN_SITE and hasattr(request, "path"):
|
||||
try:
|
||||
admin_url = reverse("admin:index")
|
||||
except NoReverseMatch:
|
||||
return True
|
||||
return not re.match("^%s" % admin_url, request.path)
|
||||
return not re.match(f"^{admin_url}", request.path)
|
||||
|
||||
return False
|
||||
|
||||
def reset_attempts(
|
||||
self,
|
||||
*,
|
||||
ip_address: str = None,
|
||||
username: str = None,
|
||||
ip_address: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
ip_or_username: bool = False,
|
||||
) -> int:
|
||||
"""
|
||||
Resets access attempts that match the given IP address or username.
|
||||
|
||||
This method makes more sense for the DB backend, but as it is used by the ProxyHandler
|
||||
(via inherent), it needs to be defined here so we get compliant with all proxy methods.
|
||||
(via inherent), it needs to be defined here, so we get compliant with all proxy methods.
|
||||
|
||||
Please overwrite it on each specialized handler as needed.
|
||||
"""
|
||||
return 0
|
||||
|
||||
def reset_logs(self, *, age_days: int = None) -> int:
|
||||
def reset_logs(self, *, age_days: Optional[int] = None) -> int:
|
||||
"""
|
||||
Resets access logs that are older than given number of days.
|
||||
|
||||
This method makes more sense for the DB backend, but as it is used by the ProxyHandler
|
||||
(via inherent), it needs to be defined here so we get compliant with all proxy methods.
|
||||
(via inherent), it needs to be defined here, so we get compliant with all proxy methods.
|
||||
|
||||
Please overwrite it on each specialized handler as needed.
|
||||
"""
|
||||
return 0
|
||||
|
||||
def reset_failure_logs(self, *, age_days: Optional[int] = None) -> int:
|
||||
"""
|
||||
Resets access failure logs that are older than given number of days.
|
||||
|
||||
This method makes more sense for the DB backend, but as it is used by the ProxyHandler
|
||||
(via inherent), it needs to be defined here, so we get compliant with all proxy methods.
|
||||
|
||||
Please overwrite it on each specialized handler as needed.
|
||||
"""
|
||||
return 0
|
||||
|
||||
def remove_out_of_limit_failure_logs(
|
||||
self, *, username: str, limit: Optional[int] = None
|
||||
) -> int:
|
||||
"""Remove access failure logs that are over
|
||||
AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT for user username.
|
||||
|
||||
This method makes more sense for the DB backend, but as it is used by the ProxyHandler
|
||||
(via inherent), it needs to be defined here, so we get compliant with all proxy methods.
|
||||
|
||||
Please overwrite it on each specialized handler as needed.
|
||||
|
||||
"""
|
||||
return 0
|
||||
|
||||
|
||||
class AxesHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||
"""
|
||||
|
|
@ -189,5 +247,5 @@ class AxesHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
def user_logged_out(self, sender, request, user, **kwargs):
|
||||
pass
|
||||
|
||||
def get_failures(self, request, credentials: dict = None) -> int:
|
||||
def get_failures(self, request, credentials: Optional[dict] = None) -> int:
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
from logging import getLogger
|
||||
from typing import Optional
|
||||
|
||||
from axes.conf import settings
|
||||
from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler
|
||||
from axes.helpers import (
|
||||
get_cache,
|
||||
get_cache_timeout,
|
||||
get_client_cache_key,
|
||||
get_client_cache_keys,
|
||||
get_client_str,
|
||||
get_client_username,
|
||||
get_credentials,
|
||||
get_failure_limit,
|
||||
get_lockout_parameters,
|
||||
)
|
||||
from axes.models import AccessAttempt
|
||||
from axes.signals import user_locked_out
|
||||
|
|
@ -24,13 +26,12 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
|
||||
def __init__(self):
|
||||
self.cache = get_cache()
|
||||
self.cache_timeout = get_cache_timeout()
|
||||
|
||||
def reset_attempts(
|
||||
self,
|
||||
*,
|
||||
ip_address: str = None,
|
||||
username: str = None,
|
||||
ip_address: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
ip_or_username: bool = False,
|
||||
) -> int:
|
||||
cache_keys: list = []
|
||||
|
|
@ -44,7 +45,7 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
)
|
||||
|
||||
cache_keys.extend(
|
||||
get_client_cache_key(
|
||||
get_client_cache_keys(
|
||||
AccessAttempt(username=username, ip_address=ip_address)
|
||||
)
|
||||
)
|
||||
|
|
@ -57,16 +58,14 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
|
||||
return count
|
||||
|
||||
def get_failures(self, request, credentials: dict = None) -> int:
|
||||
cache_keys = get_client_cache_key(request, credentials)
|
||||
def get_failures(self, request, credentials: Optional[dict] = None) -> int:
|
||||
cache_keys = get_client_cache_keys(request, credentials)
|
||||
failure_count = max(
|
||||
self.cache.get(cache_key, default=0) for cache_key in cache_keys
|
||||
)
|
||||
return failure_count
|
||||
|
||||
def user_login_failed(
|
||||
self, sender, credentials: dict, request=None, **kwargs
|
||||
): # pylint: disable=too-many-locals
|
||||
def user_login_failed(self, sender, credentials: dict, request=None, **kwargs):
|
||||
"""
|
||||
When user login fails, save attempt record in cache and lock user out if necessary.
|
||||
|
||||
|
|
@ -80,9 +79,24 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
return
|
||||
|
||||
username = get_client_username(request, credentials)
|
||||
if settings.AXES_ONLY_USER_FAILURES and username is None:
|
||||
lockout_parameters = get_lockout_parameters(request, credentials)
|
||||
if lockout_parameters == ["username"] and username is None:
|
||||
log.warning(
|
||||
"AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created."
|
||||
"AXES: Username is None and username is the only one lockout parameter, new record will NOT be created."
|
||||
)
|
||||
return
|
||||
|
||||
# If axes denied access, don't record the failed attempt as that would reset the lockout time.
|
||||
if (
|
||||
not settings.AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT
|
||||
and request.axes_locked_out
|
||||
):
|
||||
request.axes_credentials = credentials
|
||||
user_locked_out.send(
|
||||
"axes",
|
||||
request=request,
|
||||
username=username,
|
||||
ip_address=request.axes_ip_address,
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -98,7 +112,18 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
log.info("AXES: Login failed from whitelisted client %s.", client_str)
|
||||
return
|
||||
|
||||
failures_since_start = 1 + self.get_failures(request, credentials)
|
||||
cache_keys = get_client_cache_keys(request, credentials)
|
||||
cache_timeout = get_cache_timeout()
|
||||
failures = []
|
||||
for cache_key in cache_keys:
|
||||
added = self.cache.add(key=cache_key, value=1, timeout=cache_timeout)
|
||||
if added:
|
||||
failures.append(1)
|
||||
else:
|
||||
failures.append(self.cache.incr(key=cache_key, delta=1))
|
||||
self.cache.touch(key=cache_key, timeout=cache_timeout)
|
||||
|
||||
failures_since_start = max(failures)
|
||||
request.axes_failures_since_start = failures_since_start
|
||||
|
||||
if failures_since_start > 1:
|
||||
|
|
@ -114,11 +139,6 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
client_str,
|
||||
)
|
||||
|
||||
cache_keys = get_client_cache_key(request, credentials)
|
||||
for cache_key in cache_keys:
|
||||
failures = self.cache.get(cache_key, default=0)
|
||||
self.cache.set(cache_key, failures + 1, self.cache_timeout)
|
||||
|
||||
if (
|
||||
settings.AXES_LOCK_OUT_AT_FAILURE
|
||||
and failures_since_start >= get_failure_limit(request, credentials)
|
||||
|
|
@ -136,9 +156,7 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
ip_address=request.axes_ip_address,
|
||||
)
|
||||
|
||||
def user_logged_in(
|
||||
self, sender, request, user, **kwargs
|
||||
): # pylint: disable=unused-argument
|
||||
def user_logged_in(self, sender, request, user, **kwargs):
|
||||
"""
|
||||
When user logs in, update the AccessLog related to the user.
|
||||
"""
|
||||
|
|
@ -156,7 +174,7 @@ class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
log.info("AXES: Successful login by %s.", client_str)
|
||||
|
||||
if settings.AXES_RESET_ON_SUCCESS:
|
||||
cache_keys = get_client_cache_key(request, credentials)
|
||||
cache_keys = get_client_cache_keys(request, credentials)
|
||||
for cache_key in cache_keys:
|
||||
failures_since_start = self.cache.get(cache_key, default=0)
|
||||
self.cache.delete(cache_key)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
from logging import getLogger
|
||||
from typing import Optional
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Sum, Value, Q
|
||||
from django.db import router, transaction
|
||||
from django.db.models import F, Q, Sum, Value
|
||||
from django.db.models.functions import Concat
|
||||
from django.utils import timezone
|
||||
|
||||
|
|
@ -17,9 +18,10 @@ from axes.helpers import (
|
|||
get_client_username,
|
||||
get_credentials,
|
||||
get_failure_limit,
|
||||
get_lockout_parameters,
|
||||
get_query_str,
|
||||
)
|
||||
from axes.models import AccessLog, AccessAttempt
|
||||
from axes.models import AccessLog, AccessAttempt, AccessFailureLog
|
||||
from axes.signals import user_locked_out
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
|
@ -36,8 +38,8 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
def reset_attempts(
|
||||
self,
|
||||
*,
|
||||
ip_address: str = None,
|
||||
username: str = None,
|
||||
ip_address: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
ip_or_username: bool = False,
|
||||
) -> int:
|
||||
attempts = AccessAttempt.objects.all()
|
||||
|
|
@ -55,7 +57,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
|
||||
return count
|
||||
|
||||
def reset_logs(self, *, age_days: int = None) -> int:
|
||||
def reset_logs(self, *, age_days: Optional[int] = None) -> int:
|
||||
if age_days is None:
|
||||
count, _ = AccessLog.objects.all().delete()
|
||||
log.info("AXES: Reset all %d access logs from database.", count)
|
||||
|
|
@ -70,7 +72,37 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
|
||||
return count
|
||||
|
||||
def get_failures(self, request, credentials: dict = None) -> int:
|
||||
def reset_failure_logs(self, *, age_days: Optional[int] = None) -> int:
|
||||
if age_days is None:
|
||||
count, _ = AccessFailureLog.objects.all().delete()
|
||||
log.info("AXES: Reset all %d access failure logs from database.", count)
|
||||
else:
|
||||
limit = timezone.now() - timezone.timedelta(days=age_days)
|
||||
count, _ = AccessFailureLog.objects.filter(attempt_time__lte=limit).delete()
|
||||
log.info(
|
||||
"AXES: Reset %d access failure logs older than %d days from database.",
|
||||
count,
|
||||
age_days,
|
||||
)
|
||||
|
||||
return count
|
||||
|
||||
def remove_out_of_limit_failure_logs(
|
||||
self,
|
||||
*,
|
||||
username: str,
|
||||
limit: Optional[int] = settings.AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT,
|
||||
) -> int:
|
||||
count = 0
|
||||
failures = AccessFailureLog.objects.filter(username=username)
|
||||
out_of_limit_failures_logs = failures.count() - limit
|
||||
if out_of_limit_failures_logs > 0:
|
||||
for failure in failures[:out_of_limit_failures_logs]:
|
||||
failure.delete()
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def get_failures(self, request, credentials: Optional[dict] = None) -> int:
|
||||
attempts_list = get_user_attempts(request, credentials)
|
||||
attempt_count = max(
|
||||
(
|
||||
|
|
@ -83,11 +115,11 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
)
|
||||
return attempt_count
|
||||
|
||||
def user_login_failed(
|
||||
self, sender, credentials: dict, request=None, **kwargs
|
||||
): # pylint: disable=too-many-locals
|
||||
"""
|
||||
When user login fails, save AccessAttempt record in database, mark request with lockout attribute and emit lockout signal.
|
||||
def user_login_failed(self, sender, credentials: dict, request=None, **kwargs):
|
||||
"""When user login fails, save AccessFailureLog record in database,
|
||||
save AccessAttempt record in database, mark request with
|
||||
lockout attribute and emit lockout signal.
|
||||
|
||||
"""
|
||||
|
||||
log.info("AXES: User login failed, running database handler for failure.")
|
||||
|
|
@ -110,6 +142,20 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
request,
|
||||
)
|
||||
|
||||
# If axes denied access, don't record the failed attempt as that would reset the lockout time.
|
||||
if (
|
||||
not settings.AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT
|
||||
and request.axes_locked_out
|
||||
):
|
||||
request.axes_credentials = credentials
|
||||
user_locked_out.send(
|
||||
"axes",
|
||||
request=request,
|
||||
username=username,
|
||||
ip_address=request.axes_ip_address,
|
||||
)
|
||||
return
|
||||
|
||||
# This replaces null byte chars that crash saving failures.
|
||||
get_data = get_query_str(request.GET).replace("\0", "0x00")
|
||||
post_data = get_query_str(request.POST).replace("\0", "0x00")
|
||||
|
|
@ -119,9 +165,10 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
return
|
||||
|
||||
# 2. database query: Get or create access record with the new failure data
|
||||
if settings.AXES_ONLY_USER_FAILURES and username is None:
|
||||
lockout_parameters = get_lockout_parameters(request, credentials)
|
||||
if lockout_parameters == ["username"] and username is None:
|
||||
log.warning(
|
||||
"AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, 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:
|
||||
(
|
||||
|
|
@ -148,7 +195,8 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
log.warning(
|
||||
"AXES: New login failure by %s. Created new record in the database.",
|
||||
client_str,
|
||||
)
|
||||
|
||||
|
||||
|
||||
# 3. database query if there were previous attempts in the database
|
||||
# Update failed attempt information but do not touch the username, IP address, or user agent fields,
|
||||
|
|
@ -193,9 +241,21 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
ip_address=request.axes_ip_address,
|
||||
)
|
||||
|
||||
def user_logged_in(
|
||||
self, sender, request, user, **kwargs
|
||||
): # pylint: disable=unused-argument
|
||||
# 5. database entry: Log for ever the attempt in the AccessFailureLog
|
||||
if settings.AXES_ENABLE_ACCESS_FAILURE_LOG:
|
||||
with transaction.atomic(using=router.db_for_write(AccessFailureLog)):
|
||||
AccessFailureLog.objects.create(
|
||||
username=username,
|
||||
ip_address=request.axes_ip_address,
|
||||
user_agent=request.axes_user_agent,
|
||||
http_accept=request.axes_http_accept,
|
||||
path_info=request.axes_path_info,
|
||||
attempt_time=request.axes_attempt_time,
|
||||
locked_out=request.axes_locked_out,
|
||||
)
|
||||
self.remove_out_of_limit_failure_logs(username=username)
|
||||
|
||||
def user_logged_in(self, sender, request, user, **kwargs):
|
||||
"""
|
||||
When user logs in, update the AccessLog related to the user.
|
||||
"""
|
||||
|
|
@ -235,9 +295,7 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
client_str,
|
||||
)
|
||||
|
||||
def user_logged_out(
|
||||
self, sender, request, user, **kwargs
|
||||
): # pylint: disable=unused-argument
|
||||
def user_logged_out(self, sender, request, user, **kwargs):
|
||||
"""
|
||||
When user logs out, update the AccessLog related to the user.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class AxesDummyHandler(AbstractAxesHandler, AxesBaseHandler):
|
||||
|
|
@ -6,7 +7,7 @@ class AxesDummyHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
Signal handler implementation that does nothing and can be used to disable signal processing.
|
||||
"""
|
||||
|
||||
def is_allowed(self, request, credentials: dict = None) -> bool:
|
||||
def is_allowed(self, request, credentials: Optional[dict] = None) -> bool:
|
||||
return True
|
||||
|
||||
def user_login_failed(self, sender, credentials: dict, request=None, **kwargs):
|
||||
|
|
@ -18,5 +19,5 @@ class AxesDummyHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
def user_logged_out(self, sender, request, user, **kwargs):
|
||||
pass
|
||||
|
||||
def get_failures(self, request, credentials: dict = None) -> int:
|
||||
def get_failures(self, request, credentials: Optional[dict] = None) -> int:
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
# pylint: disable=arguments-differ
|
||||
# pylint generates false negatives from proxy class method overrides
|
||||
|
||||
from logging import getLogger
|
||||
from typing import Optional
|
||||
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.timezone import now
|
||||
|
|
@ -46,8 +50,8 @@ class AxesProxyHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
def reset_attempts(
|
||||
cls,
|
||||
*,
|
||||
ip_address: str = None,
|
||||
username: str = None,
|
||||
ip_address: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
ip_or_username: bool = False,
|
||||
) -> int:
|
||||
return cls.get_implementation().reset_attempts(
|
||||
|
|
@ -55,9 +59,21 @@ class AxesProxyHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
)
|
||||
|
||||
@classmethod
|
||||
def reset_logs(cls, *, age_days: int = None) -> int:
|
||||
def reset_logs(cls, *, age_days: Optional[int] = None) -> int:
|
||||
return cls.get_implementation().reset_logs(age_days=age_days)
|
||||
|
||||
@classmethod
|
||||
def reset_failure_logs(cls, *, age_days: Optional[int] = None) -> int:
|
||||
return cls.get_implementation().reset_failure_logs(age_days=age_days)
|
||||
|
||||
@classmethod
|
||||
def remove_out_of_limit_failure_logs(
|
||||
cls, *, username: str, limit: Optional[int] = None
|
||||
) -> int:
|
||||
return cls.get_implementation().remove_out_of_limit_failure_logs(
|
||||
username=username
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_request(request):
|
||||
"""
|
||||
|
|
@ -70,7 +86,8 @@ class AxesProxyHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
)
|
||||
return
|
||||
if not hasattr(request, "axes_updated"):
|
||||
request.axes_locked_out = False
|
||||
if not hasattr(request, "axes_locked_out"):
|
||||
request.axes_locked_out = False
|
||||
request.axes_attempt_time = now()
|
||||
request.axes_ip_address = get_client_ip_address(request)
|
||||
request.axes_user_agent = get_client_user_agent(request)
|
||||
|
|
@ -81,17 +98,17 @@ class AxesProxyHandler(AbstractAxesHandler, AxesBaseHandler):
|
|||
request.axes_credentials = None
|
||||
|
||||
@classmethod
|
||||
def is_locked(cls, request, credentials: dict = None) -> bool:
|
||||
def is_locked(cls, request, credentials: Optional[dict] = None) -> bool:
|
||||
cls.update_request(request)
|
||||
return cls.get_implementation().is_locked(request, credentials)
|
||||
|
||||
@classmethod
|
||||
def is_allowed(cls, request, credentials: dict = None) -> bool:
|
||||
def is_allowed(cls, request, credentials: Optional[dict] = None) -> bool:
|
||||
cls.update_request(request)
|
||||
return cls.get_implementation().is_allowed(request, credentials)
|
||||
|
||||
@classmethod
|
||||
def get_failures(cls, request, credentials: dict = None) -> int:
|
||||
def get_failures(cls, request, credentials: Optional[dict] = None) -> int:
|
||||
cls.update_request(request)
|
||||
return cls.get_implementation().get_failures(request, credentials)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
from axes.handlers.base import AxesHandler
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class AxesTestHandler(AxesHandler): # pylint: disable=unused-argument
|
||||
class AxesTestHandler(AxesHandler):
|
||||
"""
|
||||
Signal handler implementation that does nothing, ideal for a test suite.
|
||||
"""
|
||||
|
|
@ -9,17 +10,17 @@ class AxesTestHandler(AxesHandler): # pylint: disable=unused-argument
|
|||
def reset_attempts(
|
||||
self,
|
||||
*,
|
||||
ip_address: str = None,
|
||||
username: str = None,
|
||||
ip_address: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
ip_or_username: bool = False,
|
||||
) -> int:
|
||||
return 0
|
||||
|
||||
def reset_logs(self, *, age_days: int = None) -> int:
|
||||
def reset_logs(self, *, age_days: Optional[int] = None) -> int:
|
||||
return 0
|
||||
|
||||
def is_allowed(self, request, credentials: dict = None) -> bool:
|
||||
def is_allowed(self, request, credentials: Optional[dict] = None) -> bool:
|
||||
return True
|
||||
|
||||
def get_failures(self, request, credentials: dict = None) -> int:
|
||||
def get_failures(self, request, credentials: Optional[dict] = None) -> int:
|
||||
return 0
|
||||
|
|
|
|||
237
axes/helpers.py
237
axes/helpers.py
|
|
@ -1,14 +1,13 @@
|
|||
from datetime import timedelta
|
||||
from hashlib import md5
|
||||
from hashlib import sha256
|
||||
from logging import getLogger
|
||||
from string import Template
|
||||
from typing import Callable, Optional, Type, Union
|
||||
from typing import Callable, Optional, Type, Union, List
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import ipware.ip
|
||||
from django.core.cache import caches, BaseCache
|
||||
from django.core.cache import BaseCache, caches
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse, QueryDict
|
||||
from django.shortcuts import render, redirect
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from axes.conf import settings
|
||||
|
|
@ -16,6 +15,13 @@ from axes.models import AccessBase
|
|||
|
||||
log = getLogger(__name__)
|
||||
|
||||
try:
|
||||
import ipware.ip
|
||||
|
||||
IPWARE_INSTALLED = True
|
||||
except ImportError:
|
||||
IPWARE_INSTALLED = False
|
||||
|
||||
|
||||
def get_cache() -> BaseCache:
|
||||
"""
|
||||
|
|
@ -31,7 +37,7 @@ def get_cache_timeout() -> Optional[int]:
|
|||
|
||||
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
|
||||
for use with the Django cache backends.
|
||||
"""
|
||||
|
|
@ -48,7 +54,7 @@ def get_cool_off() -> Optional[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, or integer/float of hours,
|
||||
and this function offers a unified _timedelta or None_ representation of that configuration
|
||||
for use with the Axes internal implementations.
|
||||
|
||||
|
|
@ -59,10 +65,12 @@ def get_cool_off() -> Optional[timedelta]:
|
|||
|
||||
if isinstance(cool_off, int):
|
||||
return timedelta(hours=cool_off)
|
||||
if isinstance(cool_off, float):
|
||||
return timedelta(minutes=cool_off * 60)
|
||||
if isinstance(cool_off, str):
|
||||
return import_string(cool_off)()
|
||||
if callable(cool_off):
|
||||
return cool_off()
|
||||
return cool_off() # pylint: disable=not-callable
|
||||
|
||||
return cool_off
|
||||
|
||||
|
|
@ -90,7 +98,7 @@ def get_cool_off_iso8601(delta: timedelta) -> str:
|
|||
return f"P{days_str}"
|
||||
|
||||
|
||||
def get_credentials(username: 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.
|
||||
|
||||
|
|
@ -103,7 +111,9 @@ def get_credentials(username: str = None, **kwargs) -> dict:
|
|||
return credentials
|
||||
|
||||
|
||||
def get_client_username(request, credentials: dict = None) -> str:
|
||||
def get_client_username(
|
||||
request: HttpRequest, credentials: Optional[dict] = None
|
||||
) -> str:
|
||||
"""
|
||||
Resolve client username from the given request or credentials if supplied.
|
||||
|
||||
|
|
@ -121,7 +131,9 @@ def get_client_username(request, credentials: dict = None) -> str:
|
|||
log.debug("Using settings.AXES_USERNAME_CALLABLE to get username")
|
||||
|
||||
if callable(settings.AXES_USERNAME_CALLABLE):
|
||||
return settings.AXES_USERNAME_CALLABLE(request, credentials)
|
||||
return settings.AXES_USERNAME_CALLABLE( # pylint: disable=not-callable
|
||||
request, credentials
|
||||
)
|
||||
if isinstance(settings.AXES_USERNAME_CALLABLE, str):
|
||||
return import_string(settings.AXES_USERNAME_CALLABLE)(request, credentials)
|
||||
raise TypeError(
|
||||
|
|
@ -142,38 +154,96 @@ def get_client_username(request, credentials: dict = None) -> str:
|
|||
return request_data.get(settings.AXES_USERNAME_FORM_FIELD, None)
|
||||
|
||||
|
||||
def get_client_ip_address(request) -> str:
|
||||
def get_client_ip_address(
|
||||
request: HttpRequest,
|
||||
use_ipware: Optional[bool] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get client IP address as configured by the user.
|
||||
|
||||
The django-ipware package is used for address resolution
|
||||
and parameters can be configured in the Axes package.
|
||||
The order of preference for address resolution is as follows:
|
||||
|
||||
1. If configured, use ``AXES_CLIENT_IP_CALLABLE``, and supply ``request`` as argument
|
||||
2. If available, use django-ipware package (parameters can be configured in the Axes package)
|
||||
3. Use ``request.META.get('REMOTE_ADDR', None)`` as a fallback
|
||||
|
||||
:param request: incoming Django ``HttpRequest`` or similar object from authentication backend or other source
|
||||
"""
|
||||
|
||||
client_ip_address, _ = ipware.ip.get_client_ip(
|
||||
request,
|
||||
proxy_order=settings.AXES_PROXY_ORDER,
|
||||
proxy_count=settings.AXES_PROXY_COUNT,
|
||||
proxy_trusted_ips=settings.AXES_PROXY_TRUSTED_IPS,
|
||||
request_header_order=settings.AXES_META_PRECEDENCE_ORDER,
|
||||
if settings.AXES_CLIENT_IP_CALLABLE:
|
||||
log.debug("Using settings.AXES_CLIENT_IP_CALLABLE to get client IP address")
|
||||
|
||||
if callable(settings.AXES_CLIENT_IP_CALLABLE):
|
||||
return settings.AXES_CLIENT_IP_CALLABLE( # pylint: disable=not-callable
|
||||
request
|
||||
)
|
||||
if isinstance(settings.AXES_CLIENT_IP_CALLABLE, str):
|
||||
return import_string(settings.AXES_CLIENT_IP_CALLABLE)(request)
|
||||
raise TypeError(
|
||||
"settings.AXES_CLIENT_IP_CALLABLE needs to be a string, callable, or None."
|
||||
)
|
||||
|
||||
# Resolve using django-ipware from a configuration flag that can be set to False to explicitly disable
|
||||
# this is added to both enable or disable the branch when ipware is installed in the test environment
|
||||
if use_ipware is None:
|
||||
use_ipware = IPWARE_INSTALLED
|
||||
if use_ipware:
|
||||
log.debug("Using django-ipware to get client IP address")
|
||||
|
||||
client_ip_address, _ = ipware.ip.get_client_ip(
|
||||
request,
|
||||
proxy_order=settings.AXES_IPWARE_PROXY_ORDER,
|
||||
proxy_count=settings.AXES_IPWARE_PROXY_COUNT,
|
||||
proxy_trusted_ips=settings.AXES_IPWARE_PROXY_TRUSTED_IPS,
|
||||
request_header_order=settings.AXES_IPWARE_META_PRECEDENCE_ORDER,
|
||||
)
|
||||
return client_ip_address
|
||||
|
||||
log.debug(
|
||||
"Using request.META.get('REMOTE_ADDR', None) fallback method to get client IP address"
|
||||
)
|
||||
|
||||
return client_ip_address
|
||||
return request.META.get("REMOTE_ADDR", None)
|
||||
|
||||
|
||||
def get_client_user_agent(request) -> str:
|
||||
def get_client_user_agent(request: HttpRequest) -> str:
|
||||
return request.META.get("HTTP_USER_AGENT", "<unknown>")[:255]
|
||||
|
||||
|
||||
def get_client_path_info(request) -> str:
|
||||
def get_client_path_info(request: HttpRequest) -> str:
|
||||
return request.META.get("PATH_INFO", "<unknown>")[:255]
|
||||
|
||||
|
||||
def get_client_http_accept(request) -> str:
|
||||
def get_client_http_accept(request: HttpRequest) -> str:
|
||||
return request.META.get("HTTP_ACCEPT", "<unknown>")[:1025]
|
||||
|
||||
|
||||
def get_client_parameters(username: str, ip_address: str, user_agent: str) -> list:
|
||||
def get_lockout_parameters(
|
||||
request_or_attempt: Union[HttpRequest, AccessBase],
|
||||
credentials: Optional[dict] = None,
|
||||
) -> List[Union[str, List[str]]]:
|
||||
if callable(settings.AXES_LOCKOUT_PARAMETERS):
|
||||
return settings.AXES_LOCKOUT_PARAMETERS(request_or_attempt, credentials)
|
||||
|
||||
if isinstance(settings.AXES_LOCKOUT_PARAMETERS, str):
|
||||
return import_string(settings.AXES_LOCKOUT_PARAMETERS)(
|
||||
request_or_attempt, credentials
|
||||
)
|
||||
|
||||
if isinstance(settings.AXES_LOCKOUT_PARAMETERS, list):
|
||||
return settings.AXES_LOCKOUT_PARAMETERS
|
||||
|
||||
raise TypeError(
|
||||
"settings.AXES_LOCKOUT_PARAMETERS needs to be a callable or iterable"
|
||||
)
|
||||
|
||||
|
||||
def get_client_parameters(
|
||||
username: str,
|
||||
ip_address: str,
|
||||
user_agent: str,
|
||||
request_or_attempt: Union[HttpRequest, AccessBase],
|
||||
credentials: Optional[dict] = None,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Get query parameters for filtering AccessAttempt queryset.
|
||||
|
||||
|
|
@ -182,42 +252,53 @@ def get_client_parameters(username: str, ip_address: str, user_agent: str) -> li
|
|||
|
||||
Returns list of dict, every item of list are separate parameters
|
||||
"""
|
||||
lockout_parameters = get_lockout_parameters(request_or_attempt, credentials)
|
||||
|
||||
if settings.AXES_ONLY_USER_FAILURES:
|
||||
# 1. Only individual usernames can be tracked with parametrization
|
||||
filter_query = [{"username": username}]
|
||||
else:
|
||||
if settings.AXES_LOCK_OUT_BY_USER_OR_IP:
|
||||
# One of `username` or `IP address` is used
|
||||
filter_query = [{"username": username}, {"ip_address": ip_address}]
|
||||
elif settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP:
|
||||
# 2. A combination of username and IP address can be used as well
|
||||
filter_query = [{"username": username, "ip_address": ip_address}]
|
||||
else:
|
||||
# 3. Default case is to track the IP address only, which is the most secure option
|
||||
filter_query = [{"ip_address": ip_address}]
|
||||
parameters_dict = {
|
||||
"username": username,
|
||||
"ip_address": ip_address,
|
||||
"user_agent": user_agent,
|
||||
}
|
||||
|
||||
if settings.AXES_USE_USER_AGENT:
|
||||
# 4. The HTTP User-Agent can be used to track e.g. one browser
|
||||
filter_query.append({"user_agent": user_agent})
|
||||
filter_kwargs = []
|
||||
|
||||
return filter_query
|
||||
for parameter in lockout_parameters:
|
||||
try:
|
||||
if isinstance(parameter, str):
|
||||
filter_kwarg = {parameter: parameters_dict[parameter]}
|
||||
else:
|
||||
filter_kwarg = {
|
||||
combined_parameter: parameters_dict[combined_parameter]
|
||||
for combined_parameter in parameter
|
||||
}
|
||||
filter_kwargs.append(filter_kwarg)
|
||||
|
||||
except KeyError as e:
|
||||
error_msg = (
|
||||
f"{e} lockout parameter is not allowed. "
|
||||
f"Allowed parameters: {', '.join(parameters_dict.keys())}"
|
||||
)
|
||||
log.exception(error_msg)
|
||||
raise ValueError(error_msg) from e
|
||||
|
||||
return filter_kwargs
|
||||
|
||||
|
||||
def make_cache_key_list(filter_kwargs_list):
|
||||
def make_cache_key_list(filter_kwargs_list: List[dict]) -> List[str]:
|
||||
cache_keys = []
|
||||
for filter_kwargs in filter_kwargs_list:
|
||||
cache_key_components = "".join(
|
||||
value for value in filter_kwargs.values() if value
|
||||
)
|
||||
cache_key_digest = md5(cache_key_components.encode()).hexdigest()
|
||||
cache_key_digest = sha256(cache_key_components.encode()).hexdigest()
|
||||
cache_keys.append(f"axes-{cache_key_digest}")
|
||||
return cache_keys
|
||||
|
||||
|
||||
def get_client_cache_key(
|
||||
request_or_attempt: Union[HttpRequest, AccessBase], credentials: dict = None
|
||||
) -> str:
|
||||
def get_client_cache_keys(
|
||||
request_or_attempt: Union[HttpRequest, AccessBase],
|
||||
credentials: Optional[dict] = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Build cache key name from request or AccessAttempt object.
|
||||
|
||||
|
|
@ -235,7 +316,9 @@ def get_client_cache_key(
|
|||
ip_address = get_client_ip_address(request_or_attempt)
|
||||
user_agent = get_client_user_agent(request_or_attempt)
|
||||
|
||||
filter_kwargs_list = get_client_parameters(username, ip_address, user_agent)
|
||||
filter_kwargs_list = get_client_parameters(
|
||||
username, ip_address, user_agent, request_or_attempt, credentials
|
||||
)
|
||||
|
||||
return make_cache_key_list(filter_kwargs_list)
|
||||
|
||||
|
|
@ -258,7 +341,7 @@ def get_client_str(
|
|||
log.debug("Using settings.AXES_CLIENT_STR_CALLABLE to get client string.")
|
||||
|
||||
if callable(settings.AXES_CLIENT_STR_CALLABLE):
|
||||
return settings.AXES_CLIENT_STR_CALLABLE(
|
||||
return settings.AXES_CLIENT_STR_CALLABLE( # pylint: disable=not-callable
|
||||
username, ip_address, user_agent, path_info, request
|
||||
)
|
||||
if isinstance(settings.AXES_CLIENT_STR_CALLABLE, str):
|
||||
|
|
@ -269,7 +352,7 @@ def get_client_str(
|
|||
"settings.AXES_CLIENT_STR_CALLABLE needs to be a string, callable or None."
|
||||
)
|
||||
|
||||
client_dict = dict()
|
||||
client_dict = {}
|
||||
|
||||
if settings.AXES_VERBOSE:
|
||||
# Verbose mode logs every attribute that is available
|
||||
|
|
@ -278,11 +361,11 @@ def get_client_str(
|
|||
client_dict["user_agent"] = user_agent
|
||||
else:
|
||||
# Other modes initialize the attributes that are used for the actual lockouts
|
||||
client_list = get_client_parameters(username, ip_address, user_agent)
|
||||
client_list = get_client_parameters(username, ip_address, user_agent, request)
|
||||
client_dict = {}
|
||||
for client in client_list:
|
||||
client_dict.update(client)
|
||||
|
||||
client_dict = cleanse_parameters(client_dict.copy())
|
||||
# Path info is always included as last component in the client string for traceability purposes
|
||||
if path_info and isinstance(path_info, (tuple, list)):
|
||||
path_info = path_info[0]
|
||||
|
|
@ -340,9 +423,11 @@ def get_query_str(query: Type[QueryDict], max_length: int = 1024) -> str:
|
|||
return query_str[:max_length]
|
||||
|
||||
|
||||
def get_failure_limit(request, credentials) -> int:
|
||||
def get_failure_limit(request: HttpRequest, credentials) -> int:
|
||||
if callable(settings.AXES_FAILURE_LIMIT):
|
||||
return settings.AXES_FAILURE_LIMIT(request, credentials)
|
||||
return settings.AXES_FAILURE_LIMIT( # pylint: disable=not-callable
|
||||
request, credentials
|
||||
)
|
||||
if isinstance(settings.AXES_FAILURE_LIMIT, str):
|
||||
return import_string(settings.AXES_FAILURE_LIMIT)(request, credentials)
|
||||
if isinstance(settings.AXES_FAILURE_LIMIT, int):
|
||||
|
|
@ -356,10 +441,14 @@ def get_lockout_message() -> str:
|
|||
return settings.AXES_PERMALOCK_MESSAGE
|
||||
|
||||
|
||||
def get_lockout_response(request, credentials: dict = None) -> HttpResponse:
|
||||
def get_lockout_response(
|
||||
request: HttpRequest, credentials: Optional[dict] = None
|
||||
) -> HttpResponse:
|
||||
if settings.AXES_LOCKOUT_CALLABLE:
|
||||
if callable(settings.AXES_LOCKOUT_CALLABLE):
|
||||
return settings.AXES_LOCKOUT_CALLABLE(request, credentials)
|
||||
return settings.AXES_LOCKOUT_CALLABLE( # pylint: disable=not-callable
|
||||
request, credentials
|
||||
)
|
||||
if isinstance(settings.AXES_LOCKOUT_CALLABLE, str):
|
||||
return import_string(settings.AXES_LOCKOUT_CALLABLE)(request, credentials)
|
||||
raise TypeError(
|
||||
|
|
@ -385,13 +474,13 @@ def get_lockout_response(request, credentials: dict = None) -> HttpResponse:
|
|||
|
||||
if request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest":
|
||||
json_response = JsonResponse(context, status=status)
|
||||
json_response[
|
||||
"Access-Control-Allow-Origin"
|
||||
] = settings.AXES_ALLOWED_CORS_ORIGINS
|
||||
json_response["Access-Control-Allow-Origin"] = (
|
||||
settings.AXES_ALLOWED_CORS_ORIGINS
|
||||
)
|
||||
json_response["Access-Control-Allow-Methods"] = "POST, OPTIONS"
|
||||
json_response[
|
||||
"Access-Control-Allow-Headers"
|
||||
] = "Origin, Content-Type, Accept, Authorization, x-requested-with"
|
||||
json_response["Access-Control-Allow-Headers"] = (
|
||||
"Origin, Content-Type, Accept, Authorization, x-requested-with"
|
||||
)
|
||||
return json_response
|
||||
|
||||
if settings.AXES_LOCKOUT_TEMPLATE:
|
||||
|
|
@ -400,7 +489,7 @@ def get_lockout_response(request, credentials: dict = None) -> HttpResponse:
|
|||
if settings.AXES_LOCKOUT_URL:
|
||||
lockout_url = settings.AXES_LOCKOUT_URL
|
||||
query_string = urlencode({"username": context["username"]})
|
||||
url = "{}?{}".format(lockout_url, query_string)
|
||||
url = f"{lockout_url}?{query_string}"
|
||||
return redirect(url)
|
||||
|
||||
return HttpResponse(get_lockout_message(), status=status)
|
||||
|
|
@ -410,17 +499,21 @@ def is_ip_address_in_whitelist(ip_address: str) -> bool:
|
|||
if not settings.AXES_IP_WHITELIST:
|
||||
return False
|
||||
|
||||
return ip_address in settings.AXES_IP_WHITELIST
|
||||
return ( # pylint: disable=unsupported-membership-test
|
||||
ip_address in settings.AXES_IP_WHITELIST
|
||||
)
|
||||
|
||||
|
||||
def is_ip_address_in_blacklist(ip_address: str) -> bool:
|
||||
if not settings.AXES_IP_BLACKLIST:
|
||||
return False
|
||||
|
||||
return ip_address in settings.AXES_IP_BLACKLIST
|
||||
return ( # pylint: disable=unsupported-membership-test
|
||||
ip_address in settings.AXES_IP_BLACKLIST
|
||||
)
|
||||
|
||||
|
||||
def is_client_ip_address_whitelisted(request):
|
||||
def is_client_ip_address_whitelisted(request: HttpRequest):
|
||||
"""
|
||||
Check if the given request refers to a whitelisted IP.
|
||||
"""
|
||||
|
|
@ -438,7 +531,7 @@ def is_client_ip_address_whitelisted(request):
|
|||
return False
|
||||
|
||||
|
||||
def is_client_ip_address_blacklisted(request) -> bool:
|
||||
def is_client_ip_address_blacklisted(request: HttpRequest) -> bool:
|
||||
"""
|
||||
Check if the given request refers to a blacklisted IP.
|
||||
"""
|
||||
|
|
@ -454,7 +547,7 @@ def is_client_ip_address_blacklisted(request) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def is_client_method_whitelisted(request) -> bool:
|
||||
def is_client_method_whitelisted(request: HttpRequest) -> bool:
|
||||
"""
|
||||
Check if the given request uses a whitelisted method.
|
||||
"""
|
||||
|
|
@ -465,7 +558,9 @@ def is_client_method_whitelisted(request) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def is_user_attempt_whitelisted(request, credentials: dict = None) -> bool:
|
||||
def is_user_attempt_whitelisted(
|
||||
request: HttpRequest, credentials: Optional[dict] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Check if the given request or credentials refer to a whitelisted username.
|
||||
|
||||
|
|
@ -494,7 +589,7 @@ def is_user_attempt_whitelisted(request, credentials: dict = None) -> bool:
|
|||
if whitelist_callable is None:
|
||||
return False
|
||||
if callable(whitelist_callable):
|
||||
return whitelist_callable(request, credentials)
|
||||
return whitelist_callable(request, credentials) # pylint: disable=not-callable
|
||||
if isinstance(whitelist_callable, str):
|
||||
return import_string(whitelist_callable)(request, credentials)
|
||||
|
||||
|
|
|
|||
BIN
axes/locale/ar/LC_MESSAGES/django.mo
Normal file
BIN
axes/locale/ar/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
106
axes/locale/ar/LC_MESSAGES/django.po
Normal file
106
axes/locale/ar/LC_MESSAGES/django.po
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# 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: 2022-05-30 15:16+0300\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
|
||||
"&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
|
||||
|
||||
#: admin.py:27
|
||||
msgid "Form Data"
|
||||
msgstr "بيانات النموذج"
|
||||
|
||||
#: admin.py:28 admin.py:65 admin.py:100
|
||||
msgid "Meta Data"
|
||||
msgstr "البيانات الوصفية"
|
||||
|
||||
#: conf.py:97
|
||||
msgid "Account locked: too many login attempts. Please try again later."
|
||||
msgstr "الحساب مغلق: محاولات تسجيل دخول كثيرة جدًا. الرجاء معاودة المحاولة في وقت لاحق."
|
||||
|
||||
#: conf.py:105
|
||||
msgid ""
|
||||
"Account locked: too many login attempts. Contact an admin to unlock your "
|
||||
"account."
|
||||
msgstr "الحساب مغلق: محاولات تسجيل دخول كثيرة جدًا. اتصل بمسؤول لفتح حسابك."
|
||||
|
||||
#: models.py:6
|
||||
msgid "User Agent"
|
||||
msgstr "وكيل المستخدم"
|
||||
|
||||
#: 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:61
|
||||
msgid "access log"
|
||||
msgstr "سجل الدخول"
|
||||
|
||||
#: models.py:62
|
||||
msgid "access logs"
|
||||
msgstr "سجلات الدخول"
|
||||
Binary file not shown.
|
|
@ -8,7 +8,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-07-17 15:56+0200\n"
|
||||
"POT-Creation-Date: 2022-05-27 11:46+0200\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
|
@ -18,21 +18,21 @@ msgstr ""
|
|||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#: axes/admin.py:38
|
||||
#: .\axes\admin.py:27
|
||||
msgid "Form Data"
|
||||
msgstr "Form-Daten"
|
||||
|
||||
#: axes/admin.py:41 axes/admin.py:95
|
||||
#: .\axes\admin.py:28 .\axes\admin.py:65 .\axes\admin.py:100
|
||||
msgid "Meta Data"
|
||||
msgstr "Meta-Daten"
|
||||
|
||||
#: axes/conf.py:58
|
||||
#: .\axes\conf.py:97
|
||||
msgid "Account locked: too many login attempts. Please try again later."
|
||||
msgstr ""
|
||||
"Zugang gesperrt: zu viele fehlgeschlagene Anmeldeversuche. Bitte versuchen "
|
||||
"Sie es später erneut."
|
||||
|
||||
#: axes/conf.py:61
|
||||
#: .\axes\conf.py:105
|
||||
msgid ""
|
||||
"Account locked: too many login attempts. Contact an admin to unlock your "
|
||||
"account."
|
||||
|
|
@ -40,58 +40,73 @@ msgstr ""
|
|||
"Zugang gesperrt: zu viele fehlgeschlagene Anmeldeversuche. Kontaktieren Sie "
|
||||
"einen Administrator, um Ihren Zugang zu entsperren."
|
||||
|
||||
#: axes/models.py:9
|
||||
#: .\axes\models.py:6
|
||||
msgid "User Agent"
|
||||
msgstr "Browserkennung"
|
||||
|
||||
#: axes/models.py:15
|
||||
#: .\axes\models.py:8
|
||||
msgid "IP Address"
|
||||
msgstr "IP-Adresse"
|
||||
|
||||
#: axes/models.py:21
|
||||
#: .\axes\models.py:10
|
||||
msgid "Username"
|
||||
msgstr "Benutzername"
|
||||
|
||||
#: axes/models.py:35
|
||||
#: .\axes\models.py:12
|
||||
msgid "HTTP Accept"
|
||||
msgstr ""
|
||||
msgstr "HTTP-Accept"
|
||||
|
||||
#: axes/models.py:40
|
||||
#: .\axes\models.py:14
|
||||
msgid "Path"
|
||||
msgstr "Pfad"
|
||||
|
||||
#: axes/models.py:45
|
||||
#: .\axes\models.py:16
|
||||
msgid "Attempt Time"
|
||||
msgstr "Zugriffszeitpunkt"
|
||||
|
||||
#: axes/models.py:57
|
||||
#: .\axes\models.py:26
|
||||
#| msgid "access log"
|
||||
msgid "Access lock out"
|
||||
msgstr "Zugriff gesperrt"
|
||||
|
||||
#: .\axes\models.py:34
|
||||
#| msgid "access log"
|
||||
msgid "access failure"
|
||||
msgstr "Fehlgeschlagener Zugriff"
|
||||
|
||||
#: .\axes\models.py:35
|
||||
#| msgid "access logs"
|
||||
msgid "access failures"
|
||||
msgstr "Fehlgeschlagene Zugriffe"
|
||||
|
||||
#: .\axes\models.py:39
|
||||
msgid "GET Data"
|
||||
msgstr "GET-Daten"
|
||||
|
||||
#: axes/models.py:61
|
||||
#: .\axes\models.py:41
|
||||
msgid "POST Data"
|
||||
msgstr "POST-Daten"
|
||||
|
||||
#: axes/models.py:65
|
||||
#: .\axes\models.py:43
|
||||
msgid "Failed Logins"
|
||||
msgstr "Fehlgeschlagene Anmeldeversuche"
|
||||
|
||||
#: axes/models.py:76
|
||||
#: .\axes\models.py:49
|
||||
msgid "access attempt"
|
||||
msgstr "Zugriffsversuch"
|
||||
|
||||
#: axes/models.py:77
|
||||
#: .\axes\models.py:50
|
||||
msgid "access attempts"
|
||||
msgstr "Zugriffsversuche"
|
||||
|
||||
#: axes/models.py:81
|
||||
#: .\axes\models.py:55
|
||||
msgid "Logout Time"
|
||||
msgstr "Abmeldezeitpunkt"
|
||||
|
||||
#: axes/models.py:90
|
||||
#: .\axes\models.py:61
|
||||
msgid "access log"
|
||||
msgstr "Zugriffslog"
|
||||
|
||||
#: axes/models.py:91
|
||||
#: .\axes\models.py:62
|
||||
msgid "access logs"
|
||||
msgstr "Zugriffslogs"
|
||||
|
|
|
|||
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"
|
||||
BIN
axes/locale/id/LC_MESSAGES/django.mo
Normal file
BIN
axes/locale/id/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
105
axes/locale/id/LC_MESSAGES/django.po
Normal file
105
axes/locale/id/LC_MESSAGES/django.po
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
# Indonesian translation for django-axes.
|
||||
# Copyright (C) 2023
|
||||
# This file is distributed under the same license as the django-axes package.
|
||||
# Kira <kiraware@github.com>, 2023.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django-axes 6.0.4\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-06-30 09:21+0800\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: Kira <kiraware@github.com>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: id\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
#: .\axes\admin.py:27
|
||||
msgid "Form Data"
|
||||
msgstr "Data Formulir"
|
||||
|
||||
#: .\axes\admin.py:28 .\axes\admin.py:65 .\axes\admin.py:100
|
||||
msgid "Meta Data"
|
||||
msgstr "Meta Data"
|
||||
|
||||
#: .\axes\conf.py:108
|
||||
msgid "Account locked: too many login attempts. Please try again later."
|
||||
msgstr "Akun terkunci: terlalu banyak percobaan login. Silakan coba lagi nanti."
|
||||
|
||||
#: .\axes\conf.py:116
|
||||
msgid ""
|
||||
"Account locked: too many login attempts. Contact an admin to unlock your "
|
||||
"account."
|
||||
msgstr "Akun terkunci: terlalu banyak percobaan login. Hubungi admin untuk"
|
||||
" membuka kunci akun"
|
||||
|
||||
#: .\axes\models.py:6
|
||||
msgid "User Agent"
|
||||
msgstr "User Agent"
|
||||
|
||||
#: .\axes\models.py:8
|
||||
msgid "IP Address"
|
||||
msgstr "Alamat IP"
|
||||
|
||||
#: .\axes\models.py:10
|
||||
msgid "Username"
|
||||
msgstr "Nama Pengguna"
|
||||
|
||||
#: .\axes\models.py:12
|
||||
msgid "HTTP Accept"
|
||||
msgstr "HTTP Accept"
|
||||
|
||||
#: .\axes\models.py:14
|
||||
msgid "Path"
|
||||
msgstr "Path"
|
||||
|
||||
#: .\axes\models.py:16
|
||||
msgid "Attempt Time"
|
||||
msgstr "Waktu Percobaan"
|
||||
|
||||
#: .\axes\models.py:26
|
||||
msgid "Access lock out"
|
||||
msgstr "Akses terkunci"
|
||||
|
||||
#: .\axes\models.py:34
|
||||
msgid "access failure"
|
||||
msgstr "kegagalan akses"
|
||||
|
||||
#: .\axes\models.py:35
|
||||
msgid "access failures"
|
||||
msgstr "kegagalan akses"
|
||||
|
||||
#: .\axes\models.py:39
|
||||
msgid "GET Data"
|
||||
msgstr "Data GET"
|
||||
|
||||
#: .\axes\models.py:41
|
||||
msgid "POST Data"
|
||||
msgstr "Data POST"
|
||||
|
||||
#: .\axes\models.py:43
|
||||
msgid "Failed Logins"
|
||||
msgstr "Login Gagal"
|
||||
|
||||
#: .\axes\models.py:49
|
||||
msgid "access attempt"
|
||||
msgstr "upaya akses"
|
||||
|
||||
#: .\axes\models.py:50
|
||||
msgid "access attempts"
|
||||
msgstr "upaya akses"
|
||||
|
||||
#: .\axes\models.py:55
|
||||
msgid "Logout Time"
|
||||
msgstr "Waktu Logout"
|
||||
|
||||
#: .\axes\models.py:61
|
||||
msgid "access log"
|
||||
msgstr "log akses"
|
||||
|
||||
#: .\axes\models.py:62
|
||||
msgid "access logs"
|
||||
msgstr "log akses"
|
||||
Binary file not shown.
|
|
@ -8,7 +8,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-01-11 12:20+0300\n"
|
||||
"POT-Creation-Date: 2023-05-13 12:36+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
|
@ -18,80 +18,92 @@ msgstr ""
|
|||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#: axes/admin.py:38
|
||||
#: axes/admin.py:27
|
||||
msgid "Form Data"
|
||||
msgstr "Данные формы"
|
||||
|
||||
#: axes/admin.py:41 axes/admin.py:95
|
||||
#: axes/admin.py:28 axes/admin.py:65 axes/admin.py:100
|
||||
msgid "Meta Data"
|
||||
msgstr "Метаданные"
|
||||
|
||||
#: axes/conf.py:58
|
||||
#: axes/conf.py:99
|
||||
msgid "Account locked: too many login attempts. Please try again later."
|
||||
msgstr ""
|
||||
"Учетная запись заблокирована: слишком много попыток входа. "
|
||||
"Повторите попытку позже."
|
||||
"Учетная запись заблокирована: слишком много попыток входа. Повторите попытку "
|
||||
"позже."
|
||||
|
||||
#: axes/conf.py:61
|
||||
#: axes/conf.py:107
|
||||
msgid ""
|
||||
"Account locked: too many login attempts. Contact an admin to unlock your "
|
||||
"account."
|
||||
msgstr ""
|
||||
"Учетная запись заблокирована: слишком много попыток входа. "
|
||||
"Обратитесь к администратору для разблокирования учетной записи."
|
||||
"Учетная запись заблокирована: слишком много попыток входа. Свяжитесь с "
|
||||
"администратором, чтобы разблокировать учетную запись."
|
||||
|
||||
#: axes/models.py:9
|
||||
#: axes/models.py:6
|
||||
msgid "User Agent"
|
||||
msgstr "Браузер пользователя"
|
||||
msgstr "User Agent"
|
||||
|
||||
#: axes/models.py:15
|
||||
#: axes/models.py:8
|
||||
msgid "IP Address"
|
||||
msgstr "Адрес IP"
|
||||
msgstr "IP Адрес"
|
||||
|
||||
#: axes/models.py:21
|
||||
#: axes/models.py:10
|
||||
msgid "Username"
|
||||
msgstr "Пользователь"
|
||||
msgstr "Имя пользователя"
|
||||
|
||||
#: axes/models.py:35
|
||||
#: axes/models.py:12
|
||||
msgid "HTTP Accept"
|
||||
msgstr "Запрос HTTP"
|
||||
msgstr "HTTP Accept"
|
||||
|
||||
#: axes/models.py:40
|
||||
#: axes/models.py:14
|
||||
msgid "Path"
|
||||
msgstr "Путь"
|
||||
|
||||
#: axes/models.py:45
|
||||
#: axes/models.py:16
|
||||
msgid "Attempt Time"
|
||||
msgstr "Время входа"
|
||||
msgstr "Время попытки входа"
|
||||
|
||||
#: axes/models.py:57
|
||||
#: axes/models.py:26
|
||||
msgid "Access lock out"
|
||||
msgstr "Доступ запрещен"
|
||||
|
||||
#: axes/models.py:34
|
||||
msgid "access failure"
|
||||
msgstr "Ошибка доступа"
|
||||
|
||||
#: axes/models.py:35
|
||||
msgid "access failures"
|
||||
msgstr "Ошибки доступа"
|
||||
|
||||
#: axes/models.py:39
|
||||
msgid "GET Data"
|
||||
msgstr "Данные GET-запроса"
|
||||
|
||||
#: axes/models.py:61
|
||||
#: axes/models.py:41
|
||||
msgid "POST Data"
|
||||
msgstr "Данные POST-запроса"
|
||||
|
||||
#: axes/models.py:65
|
||||
#: axes/models.py:43
|
||||
msgid "Failed Logins"
|
||||
msgstr "Ошибочные попытки"
|
||||
|
||||
#: axes/models.py:76
|
||||
#: axes/models.py:49
|
||||
msgid "access attempt"
|
||||
msgstr "Запись о попытке доступа"
|
||||
|
||||
#: axes/models.py:77
|
||||
#: axes/models.py:50
|
||||
msgid "access attempts"
|
||||
msgstr "Попытки доступа"
|
||||
|
||||
#: axes/models.py:81
|
||||
#: axes/models.py:55
|
||||
msgid "Logout Time"
|
||||
msgstr "Время выхода"
|
||||
|
||||
#: axes/models.py:90
|
||||
#: axes/models.py:61
|
||||
msgid "access log"
|
||||
msgstr "Запись о доступе"
|
||||
|
||||
#: axes/models.py:91
|
||||
#: axes/models.py:62
|
||||
msgid "access logs"
|
||||
msgstr "Логи доступа"
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -6,7 +6,7 @@ from axes.models import AccessAttempt
|
|||
class Command(BaseCommand):
|
||||
help = "List access attempts"
|
||||
|
||||
def handle(self, *args, **options): # pylint: disable=unused-argument
|
||||
def handle(self, *args, **options):
|
||||
for obj in AccessAttempt.objects.all():
|
||||
self.stdout.write(
|
||||
f"{obj.ip_address}\t{obj.username}\t{obj.failures_since_start}"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from axes.utils import reset
|
|||
class Command(BaseCommand):
|
||||
help = "Reset all access attempts and lockouts"
|
||||
|
||||
def handle(self, *args, **options): # pylint: disable=unused-argument
|
||||
def handle(self, *args, **options):
|
||||
count = reset()
|
||||
|
||||
if count:
|
||||
|
|
|
|||
22
axes/management/commands/axes_reset_failure_logs.py
Normal file
22
axes/management/commands/axes_reset_failure_logs.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
|
||||
from axes.handlers.proxy import AxesProxyHandler
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Reset access failure log records older than given days."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--age",
|
||||
type=int,
|
||||
default=30,
|
||||
help="Maximum age for records to keep in days",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
count = AxesProxyHandler.reset_failure_logs(age_days=options["age"])
|
||||
if count:
|
||||
self.stdout.write(f"{count} logs removed.")
|
||||
else:
|
||||
self.stdout.write("No logs found.")
|
||||
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 +0,0 @@
|
|||
axes_reset_username.py
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
from typing import Callable
|
||||
|
||||
from asgiref.sync import iscoroutinefunction, markcoroutinefunction, sync_to_async
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
|
||||
from axes.helpers import get_lockout_response
|
||||
|
||||
|
|
@ -29,15 +31,37 @@ class AxesMiddleware:
|
|||
- ``AXES_PERMALOCK_MESSAGE``.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response: Callable):
|
||||
async_capable = True
|
||||
sync_capable = True
|
||||
|
||||
def __init__(self, get_response: Callable) -> None:
|
||||
self.get_response = get_response
|
||||
if iscoroutinefunction(self.get_response):
|
||||
markcoroutinefunction(self)
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
# Exit out to async mode, if needed
|
||||
if iscoroutinefunction(self):
|
||||
return self.__acall__(request)
|
||||
|
||||
def __call__(self, 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, credentials) # type: ignore
|
||||
|
||||
return response
|
||||
|
||||
async def __acall__(self, request: HttpRequest) -> HttpResponse:
|
||||
response = await self.get_response(request)
|
||||
|
||||
if settings.AXES_ENABLED:
|
||||
if getattr(request, "axes_locked_out", None):
|
||||
credentials = getattr(request, "axes_credentials", None)
|
||||
response = await sync_to_async(
|
||||
get_lockout_response, thread_sensitive=True
|
||||
)(
|
||||
request, credentials
|
||||
) # type: ignore
|
||||
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("axes", "0001_initial")]
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ from django.db import models, migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("axes", "0002_auto_20151217_2044")]
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("axes", "0003_auto_20160322_0929")]
|
||||
|
||||
operations = [
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("axes", "0004_auto_20181024_1538")]
|
||||
|
||||
operations = [migrations.RemoveField(model_name="accessattempt", name="trusted")]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("axes", "0005_remove_accessattempt_trusted")]
|
||||
|
||||
operations = [migrations.RemoveField(model_name="accesslog", name="trusted")]
|
||||
|
|
|
|||
|
|
@ -1,30 +1,35 @@
|
|||
# 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
|
||||
|
||||
|
||||
def deduplicate_attempts(apps, schema_editor):
|
||||
AccessAttempt = apps.get_model("axes", "AccessAttempt")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
if db_alias != router.db_for_write(AccessAttempt):
|
||||
return
|
||||
|
||||
duplicated_attempts = (
|
||||
AccessAttempt.objects.values("username", "user_agent", "ip_address")
|
||||
AccessAttempt.objects.using(db_alias)
|
||||
.values("username", "user_agent", "ip_address")
|
||||
.annotate(Count("id"))
|
||||
.order_by()
|
||||
.filter(id__count__gt=1)
|
||||
)
|
||||
|
||||
for attempt in duplicated_attempts:
|
||||
redundant_attempts = AccessAttempt.objects.filter(
|
||||
redundant_attempts = AccessAttempt.objects.using(db_alias).filter(
|
||||
username=attempt["username"],
|
||||
user_agent=attempt["user_agent"],
|
||||
ip_address=attempt["ip_address"],
|
||||
)[1:]
|
||||
for redundant_attempt in redundant_attempts:
|
||||
redundant_attempt.delete()
|
||||
redundant_attempt.delete(using=db_alias)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("axes", "0006_remove_accesslog_trusted"),
|
||||
]
|
||||
|
|
|
|||
68
axes/migrations/0008_accessfailurelog.py
Normal file
68
axes/migrations/0008_accessfailurelog.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Generated by Django 3.2.12 on 2022-03-15 03:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("axes", "0007_alter_accessattempt_unique_together"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AccessFailureLog",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_agent",
|
||||
models.CharField(
|
||||
db_index=True, max_length=255, verbose_name="User Agent"
|
||||
),
|
||||
),
|
||||
(
|
||||
"ip_address",
|
||||
models.GenericIPAddressField(
|
||||
db_index=True, null=True, verbose_name="IP Address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
db_index=True,
|
||||
max_length=255,
|
||||
null=True,
|
||||
verbose_name="Username",
|
||||
),
|
||||
),
|
||||
(
|
||||
"http_accept",
|
||||
models.CharField(max_length=1025, verbose_name="HTTP Accept"),
|
||||
),
|
||||
("path_info", models.CharField(max_length=255, verbose_name="Path")),
|
||||
(
|
||||
"attempt_time",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, verbose_name="Attempt Time"
|
||||
),
|
||||
),
|
||||
(
|
||||
"locked_out",
|
||||
models.BooleanField(
|
||||
blank=True, default=False, verbose_name="Access lock out"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "access failure",
|
||||
"verbose_name_plural": "access failures",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -21,6 +21,20 @@ class AccessBase(models.Model):
|
|||
ordering = ["-attempt_time"]
|
||||
|
||||
|
||||
class AccessFailureLog(AccessBase):
|
||||
locked_out = models.BooleanField(
|
||||
_("Access lock out"), null=False, blank=True, default=False
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
locked_out_str = " locked out" if self.locked_out else ""
|
||||
return f"Failed access: user {self.username}{locked_out_str} on {self.attempt_time} from {self.ip_address}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("access failure")
|
||||
verbose_name_plural = _("access failures")
|
||||
|
||||
|
||||
class AccessAttempt(AccessBase):
|
||||
get_data = models.TextField(_("GET Data"))
|
||||
|
||||
|
|
|
|||
|
|
@ -10,14 +10,15 @@ from typing import Optional
|
|||
|
||||
from django.http import HttpRequest
|
||||
|
||||
from axes.conf import settings
|
||||
from axes.handlers.proxy import AxesProxyHandler
|
||||
from axes.helpers import get_client_ip_address
|
||||
from axes.helpers import get_client_ip_address, get_lockout_parameters
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
def reset(ip: str = None, username: str = None, ip_or_username=False) -> int:
|
||||
def reset(
|
||||
ip: Optional[str] = None, username: Optional[str] = None, ip_or_username=False
|
||||
) -> int:
|
||||
"""
|
||||
Reset records that match IP or username, and return the count of removed attempts.
|
||||
|
||||
|
|
@ -35,23 +36,38 @@ def reset_request(request: HttpRequest) -> int:
|
|||
|
||||
This utility method is meant to be used from the CLI or via Python API.
|
||||
"""
|
||||
lockout_paramaters = get_lockout_parameters(request)
|
||||
|
||||
ip: Optional[str] = get_client_ip_address(request)
|
||||
username = request.GET.get("username", None)
|
||||
|
||||
ip_or_username = settings.AXES_LOCK_OUT_BY_USER_OR_IP
|
||||
if settings.AXES_ONLY_USER_FAILURES:
|
||||
ip_required = False
|
||||
username_required = False
|
||||
ip_and_username = False
|
||||
|
||||
for param in lockout_paramaters:
|
||||
# hack: in works with all iterables, including strings
|
||||
# so this checks works with separate parameters
|
||||
# and with parameters combinations
|
||||
if "username" in param and "ip_address" in param:
|
||||
ip_and_username = True
|
||||
ip_required = True
|
||||
username_required = True
|
||||
break
|
||||
if "username" in param:
|
||||
username_required = True
|
||||
elif "ip_address" in param:
|
||||
ip_required = True
|
||||
|
||||
ip_or_username = not ip_and_username and ip_required and username_required
|
||||
if not ip_required:
|
||||
ip = None
|
||||
elif not (
|
||||
settings.AXES_LOCK_OUT_BY_USER_OR_IP
|
||||
or settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP
|
||||
):
|
||||
if not username_required:
|
||||
username = None
|
||||
|
||||
if not ip and not username:
|
||||
return 0
|
||||
# We don't want to reset everything, if there is some wrong request parameter
|
||||
|
||||
# if settings.AXES_USE_USER_AGENT:
|
||||
# TODO: reset based on user_agent?
|
||||
return reset(ip, username, ip_or_username)
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
Requirements
|
||||
============
|
||||
|
||||
Axes requires a supported Django version and runs on Python versions 3.6 and above.
|
||||
Axes requires a supported Django version and runs on Python versions 3.8 and above.
|
||||
|
||||
Refer to the project source code repository in
|
||||
`GitHub <https://github.com/jazzband/django-axes/>`_ and see the
|
||||
`Tox configuration <https://github.com/jazzband/django-axes/blob/master/tox.ini>`_ and
|
||||
`pyproject.toml file <https://github.com/jazzband/django-axes/blob/master/pyproject.toml>`_ and
|
||||
`Python package definition <https://github.com/jazzband/django-axes/blob/master/setup.py>`_
|
||||
to check if your Django and Python version are supported.
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ Installation
|
|||
|
||||
Axes is easy to install from the PyPI package::
|
||||
|
||||
$ pip install django-axes
|
||||
$ pip install django-axes[ipware] # use django-ipware for resolving client IP addresses OR
|
||||
$ pip install django-axes # implement and configure custom AXES_CLIENT_IP_CALLABLE
|
||||
|
||||
After installing the package, the project settings need to be configured.
|
||||
|
||||
|
|
@ -23,16 +24,21 @@ After installing the package, the project settings need to be configured.
|
|||
'axes',
|
||||
]
|
||||
|
||||
**2.** Add ``axes.backends.AxesBackend`` to the top of ``AUTHENTICATION_BACKENDS``::
|
||||
**2.** Add ``axes.backends.AxesStandaloneBackend`` to the top of ``AUTHENTICATION_BACKENDS``::
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
# AxesBackend should be the first backend in the AUTHENTICATION_BACKENDS list.
|
||||
'axes.backends.AxesBackend',
|
||||
# AxesStandaloneBackend should be the first backend in the AUTHENTICATION_BACKENDS list.
|
||||
'axes.backends.AxesStandaloneBackend',
|
||||
|
||||
# Django ModelBackend is the default authentication backend.
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
]
|
||||
|
||||
For backwards compatibility, ``AxesBackend`` can be used in place of ``AxesStandaloneBackend``.
|
||||
The only difference is that ``AxesBackend`` also provides the permissions-checking functionality
|
||||
of Django's ``ModelBackend`` behind the scenes. We recommend using ``AxesStandaloneBackend``
|
||||
if you have any custom logic to override Django's standard permissions checks.
|
||||
|
||||
**3.** Add ``axes.middleware.AxesMiddleware`` to your list of ``MIDDLEWARE``::
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
|
@ -69,6 +75,76 @@ Many people have different configurations for their development and production e
|
|||
and running the application with misconfigured settings can prevent security features from working.
|
||||
|
||||
|
||||
Version 6 breaking changes and upgrading from django-axes version 5
|
||||
-------------------------------------------------------------------
|
||||
|
||||
If you have not specialized ``django-axes`` configuration in any way
|
||||
you do not have to update any of the configuration.
|
||||
|
||||
The instructions apply to users who have configured ``django-axes`` in their projects
|
||||
and have used flags that are deprecated. The deprecated flags will be removed in the future
|
||||
but are compatible for at least version 6.0 of ``django-axes``.
|
||||
|
||||
The following flags and configuration have changed:
|
||||
|
||||
``django-ipware`` has become an optional dependency.
|
||||
To keep old behaviour, use ``pip install django-axes[ipware]``
|
||||
in your install script or use ``django-axes[ipware]``
|
||||
in your requirements file(s) instead of plain ``django-axes``.
|
||||
The new ``django-axes`` package does not include ``django-ipware`` by default
|
||||
but does use ``django-ipware`` if it is installed
|
||||
and no callables for IP address resolution are configured
|
||||
with the ``settings.AXES_CLIENT_IP_CALLABLE`` configuration flag.
|
||||
|
||||
``django-ipware`` related flags have changed names.
|
||||
The old flags have been deprecated and will be removed in the future.
|
||||
To keep old behaviour, rename them in your settings file:
|
||||
|
||||
- ``settings.AXES_PROXY_ORDER`` is now ``settings.AXES_IPWARE_PROXY_ORDER``,
|
||||
- ``settings.AXES_PROXY_COUNT`` is now ``settings.AXES_IPWARE_PROXY_COUNT``,
|
||||
- ``settings.AXES_PROXY_TRUSTED_IPS`` is now ``settings.AXES_IPWARE_PROXY_TRUSTED_IPS``, and
|
||||
- ``settings.AXES_META_PRECEDENCE_ORDER`` is now ``settings.AXES_IPWARE_META_PRECEDENCE_ORDER``.
|
||||
|
||||
``settings.AXES_LOCKOUT_PARAMETERS`` configuration flag has been added which supersedes the following configuration keys:
|
||||
|
||||
#. No configuration for failure tracking in the following items (default behaviour).
|
||||
#. ``settings.AXES_ONLY_USER_FAILURES``,
|
||||
#. ``settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP``,
|
||||
#. ``settings.AXES_LOCK_OUT_BY_USER_OR_IP``, and
|
||||
#. ``settings.AXES_USE_USER_AGENT``.
|
||||
|
||||
To keep old behaviour with the new flag, configure the following:
|
||||
|
||||
#. If you did not use any flags, use ``settings.AXES_LOCKOUT_PARAMETERS = ["ip_address"]``,
|
||||
#. If you used ``settings.AXES_ONLY_USER_FAILURES``, use ``settings.AXES_LOCKOUT_PARAMETERS = ["username"]``,
|
||||
#. If you used ``settings.AXES_LOCK_OUT_BY_USER_OR_IP``, use ``settings.AXES_LOCKOUT_PARAMETERS = ["username", "ip_address"]``, and
|
||||
#. If you used ``settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP``, use ``settings.AXES_LOCKOUT_PARAMETERS = [["username", "ip_address"]]``,
|
||||
#. If you used ``settings.AXES_USE_USER_AGENT``, add ``"user_agent"`` to your list(s) of lockout parameters.
|
||||
#. ``settings.AXES_USE_USER_AGENT`` would become ``settings.AXES_LOCKOUT_PARAMETERS = [["ip_address", "user_agent"]]``
|
||||
#. ``settings.AXES_USE_USER_AGENT`` with ``settings.AXES_ONLY_USER_FAILURES`` would become ``settings.AXES_LOCKOUT_PARAMETERS = [["username", "user_agent"]]``
|
||||
#. ``settings.AXES_USE_USER_AGENT`` with ``settings.AXES_LOCK_OUT_BY_USER_OR_IP`` would become ``settings.AXES_LOCKOUT_PARAMETERS = [["ip_address", "user_agent"], "username"]``
|
||||
#. ``settings.AXES_USE_USER_AGENT`` with ``settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP`` would become ``settings.AXES_LOCKOUT_PARAMETERS = [["ip_address", "user_agent", "username"]]``
|
||||
#. Other combinations of flags were previously not considered; the flags had precedence over each other as described in the documentation but were less-than-trivial to understand in their previous form. The new form is more explicit and flexible, although it requires more in-depth configuration.
|
||||
|
||||
The new lockout parameters define a combined list of attributes to consider when tracking failed authentication attempts.
|
||||
They can be any combination of ``username``, ``ip_address`` or ``user_agent`` in a list of strings or list of lists of strings.
|
||||
The attributes defined in the lists are combined and saved into the database, cache, or other backend for failed logins.
|
||||
The semantics of the evaluation are available in the documentation and ``axes.helpers.get_client_parameters`` callable.
|
||||
|
||||
``settings.AXES_HTTP_RESPONSE_CODE`` default has been changed from ``403`` (Forbidden) to ``429`` (Too Many Requests).
|
||||
To keep the old behavior, set ``settings.AXES_HTTP_RESPONSE_CODE = 403`` in your settings.
|
||||
|
||||
``axes.handlers.base.AxesBaseHandler.is_admin_site`` has been deprecated due to misleading naming
|
||||
in favour of better-named ``axes.handlers.base.AxesBaseHandler.is_admin_request``.
|
||||
The old implementation has been kept for backwards compatibility, but will be removed in the future.
|
||||
The old implementation checked if a request is NOT made for an admin site if ``settings.AXES_ONLY_ADMIN_SITE`` was set.
|
||||
The new implementation correctly checks if a request is made for an admin site.
|
||||
|
||||
``axes.handlers.cache.AxesCacheHandler`` has been updated to use atomic ``cache.incr`` calls
|
||||
instead of old ``cache.set`` calls in authentication failure tracking
|
||||
to enable better parallel backend support for atomic cache backends like Redis and Memcached.
|
||||
|
||||
|
||||
Disabling Axes system checks
|
||||
----------------------------
|
||||
|
||||
|
|
@ -122,7 +198,7 @@ other code, preventing the login mechanisms from working due to e.g. exception
|
|||
being thrown in some part of the code, preventing access attempts being logged
|
||||
to database with Axes or causing similar problems.
|
||||
|
||||
If new attempts or log objects are not being correctly written to the Axes tables,
|
||||
If new attempts or log objects are not being correctly written to the Axes tables,
|
||||
it is possible to configure Django ``ATOMIC_REQUESTS`` setting to to ``False``::
|
||||
|
||||
ATOMIC_REQUESTS = False
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ Resetting attempts from command line
|
|||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Axes offers a command line interface with
|
||||
``axes_reset``, ``axes_reset_ip``, 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:
|
||||
|
||||
- ``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.
|
||||
- ``python manage.py axes_reset_username [username ...]``
|
||||
will clear lockouts and records for the given usernames.
|
||||
- ``python manage.py axes_reset_ip_username [ip] [username]``
|
||||
will clear lockouts and records for the given IP address and username.
|
||||
- ``python manage.py axes_reset_logs (age)``
|
||||
will reset (i.e. delete) AccessLog records that are older
|
||||
than the given age where the default is 30 days.
|
||||
|
|
@ -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``
|
||||
that attempts that have both the set IP and username are reset.
|
||||
The effective behaviour of ``reset`` is to ``and`` the terms instead of ``or`` ing them.
|
||||
|
||||
|
||||
|
||||
Data privacy and GDPR
|
||||
---------------------
|
||||
|
||||
Most European countries have quite strict laws regarding data protection and privacy. It's highly recommended and good
|
||||
practice to treat your sensitive user data with care. The general rule here is that you shouldn't store what you don't need.
|
||||
|
||||
When dealing with brute-force protection, the IP address and the username (often the email address) are most crucial.
|
||||
Given that you can perfectly use `django-axes` without locking the user out by IP but by username, it does make sense to
|
||||
avoid storing the IP address at all. You can not lose what you don't have.
|
||||
|
||||
You can adjust the AXES settings as follows::
|
||||
|
||||
# Block by Username only (i.e.: Same user different IP is still blocked, but different user same IP is not)
|
||||
AXES_LOCKOUT_PARAMETERS = ["username"]
|
||||
|
||||
# Disable logging the IP-Address of failed login attempts by returning None for attempts to get the IP
|
||||
# Ignore assigning a lambda function to a variable for brevity
|
||||
AXES_CLIENT_IP_CALLABLE = lambda x: None # noqa: E731
|
||||
|
|
|
|||
|
|
@ -14,110 +14,77 @@ Configuring project settings
|
|||
|
||||
The following ``settings.py`` options are available for customizing Axes behaviour.
|
||||
|
||||
* ``AXES_ENABLED``: Enable or disable Axes plugin functionality,
|
||||
for example in test runner setup. Default: ``True``
|
||||
* ``AXES_FAILURE_LIMIT``: 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'``.
|
||||
Default: ``3``
|
||||
* ``AXES_LOCK_OUT_AT_FAILURE``: After the number of allowed login attempts
|
||||
are exceeded, should we lock out this IP (and optional user agent)?
|
||||
Default: ``True``
|
||||
* ``AXES_COOLOFF_TIME``: 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 callable,
|
||||
or a string path to a callable which takes no arguments.
|
||||
If an integer, will be interpreted as a number of hours.
|
||||
Default: ``None``
|
||||
* ``AXES_ONLY_ADMIN_SITE``: 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.
|
||||
Default: ``False``
|
||||
* ``AXES_ONLY_USER_FAILURES`` : If ``True``, only lock based on username,
|
||||
and never lock based on IP if attempts exceed the limit.
|
||||
Otherwise utilize the existing IP and user locking logic.
|
||||
Default: ``False``
|
||||
* ``AXES_ENABLE_ADMIN``: If ``True``, admin views for access attempts and
|
||||
logins are shown in Django admin interface.
|
||||
Default: ``True``
|
||||
* ``AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP``: If ``True``, prevent login
|
||||
from IP under a particular username if the attempt limit has been exceeded,
|
||||
otherwise lock out based on IP.
|
||||
Default: ``False``
|
||||
* ``AXES_LOCK_OUT_BY_USER_OR_IP``: If ``True``, prevent login
|
||||
from if the attempt limit has been exceeded for IP or username.
|
||||
Default: ``False``
|
||||
* ``AXES_USE_USER_AGENT``: If ``True``, lock out and log based on the IP address
|
||||
and the user agent. This means requests from different user agents but from
|
||||
the same IP are treated differently. This settings has no effect if the
|
||||
``AXES_ONLY_USER_FAILURES`` setting is active.
|
||||
Default: ``False``
|
||||
* ``AXES_HANDLER``: The path to the handler class to use.
|
||||
If set, overrides the default signal handler backend.
|
||||
Default: ``'axes.handlers.database.AxesDatabaseHandler'``
|
||||
* ``AXES_CACHE``: The name of the cache for Axes to use.
|
||||
Default: ``'default'``
|
||||
* ``AXES_LOCKOUT_TEMPLATE``: If set, specifies a template to render when a
|
||||
user is locked out. Template receives ``cooloff_timedelta``, ``cooloff_time``, ``username`` and ``failure_limit`` as
|
||||
context variables.
|
||||
Default: ``None``
|
||||
* ``AXES_LOCKOUT_URL``: If set, specifies a URL to redirect to on lockout. If both
|
||||
``AXES_LOCKOUT_TEMPLATE`` and ``AXES_LOCKOUT_URL`` are set, the template will be used.
|
||||
Default: ``None``
|
||||
* ``AXES_VERBOSE``: If ``True``, you'll see slightly more logging for Axes.
|
||||
Default: ``True``
|
||||
* ``AXES_USERNAME_FORM_FIELD``: the name of the form field that contains your users usernames.
|
||||
Default: ``username``
|
||||
* ``AXES_USERNAME_CALLABLE``: 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``: 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_whilisted(request: HttpRequest, credentials: dict) -> bool: ...``.
|
||||
This can be any callable similarly to ``AXES_USERNAME_CALLABLE``.
|
||||
Default: ``None``
|
||||
* ``AXES_LOCKOUT_CALLABLE``: 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.
|
||||
Default: ``None``
|
||||
* ``AXES_PASSWORD_FORM_FIELD``: the name of the form or credentials field that contains your users password.
|
||||
Default: ``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.
|
||||
Default: ``[]``
|
||||
* ``AXES_NEVER_LOCKOUT_GET``: If ``True``, Axes will never lock out HTTP GET requests.
|
||||
Default: ``False``
|
||||
* ``AXES_NEVER_LOCKOUT_WHITELIST``: If ``True``, users can always login from whitelisted IP addresses.
|
||||
Default: ``False``
|
||||
* ``AXES_IP_BLACKLIST``: An iterable of IPs to be blacklisted.
|
||||
Takes precedence over whitelists. For example: ``AXES_IP_BLACKLIST = ['0.0.0.0']``.
|
||||
Default: ``None``
|
||||
* ``AXES_IP_WHITELIST``: An iterable of IPs to be whitelisted.
|
||||
For example: ``AXES_IP_WHITELIST = ['0.0.0.0']``.
|
||||
Default: ``None``
|
||||
* ``AXES_DISABLE_ACCESS_LOG``: If ``True``, disable writing login and logout access logs to database,
|
||||
so the admin interface will not have user login trail for successful user authentication.
|
||||
Default: ``False``
|
||||
* ``AXES_RESET_ON_SUCCESS``: If ``True``, a successful login will reset the number of failed logins.
|
||||
Default: ``False``
|
||||
* ``AXES_ALLOWED_CORS_ORIGINS``: Configures lockout response CORS headers for XHR requests.
|
||||
Default: ``*``
|
||||
* ``AXES_HTTP_RESPONSE_CODE``: Sets the http response code returned when ``AXES_FAILURE_LIMIT`` is
|
||||
reached.
|
||||
For example: ``AXES_HTTP_RESPONSE_CODE = 429``
|
||||
Default: ``403``
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| Variable | Default | Explanation |
|
||||
+======================================================+==============================================+===========================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================+
|
||||
| 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_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_ONLY_ADMIN_SITE | False | If ``True``, lock is only enabled for admin site. Admin site is determined by checking request path against the path of ``"admin:index"`` view. If admin urls are not registered in current urlconf, all requests will not be locked. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_ONLY_USER_FAILURES | False | DEPRECATED: USE ``AXES_LOCKOUT_PARAMETERS`` INSTEAD. If ``True``, only lock based on username, and never lock based on IP if attempts exceed the limit. Otherwise utilize the existing IP and user locking logic. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_ENABLE_ADMIN | True | If ``True``, admin views for access attempts and logins are shown in Django admin interface. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP | False | DEPRECATED: USE ``AXES_LOCKOUT_PARAMETERS`` INSTEAD. If ``True``, prevent login from IP under a particular username if the attempt limit has been exceeded, otherwise lock out based on IP. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_LOCK_OUT_BY_USER_OR_IP | False | DEPRECATED: USE ``AXES_LOCKOUT_PARAMETERS`` INSTEAD. If ``True``, prevent login from if the attempt limit has been exceeded for IP or username. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_USE_USER_AGENT | False | DEPRECATED: USE ``AXES_LOCKOUT_PARAMETERS`` INSTEAD. If ``True``, lock out and log based on the IP address and the user agent. This means requests from different user agents but from the same IP are treated differently. This settings has no effect if the ``AXES_ONLY_USER_FAILURES`` setting is active. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_HANDLER | 'axes.handlers.database.AxesDatabaseHandler' | The path to the handler class to use. If set, overrides the default signal handler backend. Default: ``'axes.handlers.database.AxesDatabaseHandler'`` |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_CACHE | 'default' | The name of the cache for Axes to use. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_LOCKOUT_TEMPLATE | None | If set, specifies a template to render when a user is locked out. Template receives ``cooloff_timedelta``, ``cooloff_time``, ``username`` and ``failure_limit`` as context variables. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_LOCKOUT_URL | None | If set, specifies a URL to redirect to on lockout. If both ``AXES_LOCKOUT_TEMPLATE`` and ``AXES_LOCKOUT_URL`` are set, the template will be used. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| 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_CALLABLE | None | A callable or a string path to callable that takes two arguments for user lookups: ``def get_username(request: HttpRequest, credentials: dict) -> str: ...``. This can be any callable such as ``AXES_USERNAME_CALLABLE = lambda request, credentials: 'username'`` or a full Python module path to callable such as ``AXES_USERNAME_CALLABLE = 'example.get_username``. The ``request`` is a HttpRequest like object and the ``credentials`` is a dictionary like object. ``credentials`` are the ones that were passed to Django ``authenticate()`` in the login flow. If no function is supplied, Axes fetches the username from the ``credentials`` or ``request.POST`` dictionaries based on ``AXES_USERNAME_FORM_FIELD``. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_WHITELIST_CALLABLE | None | A callable or a string path to callable that takes two arguments for whitelisting determination and returns True, if user should be whitelisted: ``def is_whitelisted(request: HttpRequest, credentials: dict) -> bool: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_LOCKOUT_CALLABLE | None | A callable or a string path to callable that takes two arguments returns a response. For example: ``def generate_lockout_response(request: HttpRequest, credentials: dict) -> HttpResponse: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. If not callable is defined, then the default implementation in ``axes.helpers.get_lockout_response`` is used for determining the correct lockout response that is sent to the requesting client. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_CLIENT_IP_CALLABLE | None | A callable or a string path to callable that takes two arguments returns a response. For example: ``def get_ip(request: HttpRequest) -> str: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. If not callable is defined, then the default implementation in ``axes.helpers.get_client_ip_address`` is used. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_PASSWORD_FORM_FIELD | 'password' | The name of the form or credentials field that contains your users password. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_SENSITIVE_PARAMETERS | ["username", "ip_address"] | Configures POST and GET parameter values (in addition to the value of ``AXES_PASSWORD_FORM_FIELD``) to mask in login attempt logging. Defaults enable privacy-by-design. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_NEVER_LOCKOUT_GET | False | If ``True``, Axes will never lock out HTTP GET requests. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_NEVER_LOCKOUT_WHITELIST | False | If ``True``, users can always login from whitelisted IP addresses. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_IP_BLACKLIST | None | An iterable of IPs to be blacklisted. Takes precedence over whitelists. For example: ``AXES_IP_BLACKLIST = ['0.0.0.0']``. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_IP_WHITELIST | None | An iterable of IPs to be whitelisted. For example: ``AXES_IP_WHITELIST = ['0.0.0.0']``. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_DISABLE_ACCESS_LOG | False | If ``True``, disable writing login and logout access logs to database, so the admin interface will not have user login trail for successful user authentication. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_ENABLE_ACCESS_FAILURE_LOG | False | If ``True``, enable writing login failure logs to database, so you will have every user login trail for unsuccessful user authentication. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT | 1000 | Sets the number of failures to trail for each user. When the access failure log reach this number of records, an automatic removal is ran. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_RESET_ON_SUCCESS | False | If ``True``, a successful login will reset the number of failed logins. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| AXES_ALLOWED_CORS_ORIGINS | "*" | Configures lockout response CORS headers for XHR requests. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
| 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_LOCKOUT_PARAMETERS | ["ip_address"] | A list of parameters that Axes uses to lock out users. It can also be callable, which takes an http request or AccesAttempt object and credentials and returns a list of parameters. Each parameter can be a string (a single parameter) or a list of strings (a combined parameter). For example, if you configure ``AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]``, axes will block clients by ip and/or username and user agent combination. See :ref:`customizing-lockout-parameters` for more details. |
|
||||
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|
||||
|
||||
The configuration option precedences for the access attempt monitoring are:
|
||||
|
||||
|
|
@ -138,8 +105,8 @@ and uses some conservative configuration parameters by default for security.
|
|||
If you are using reverse proxies, you will need to configure one or more of the
|
||||
following settings to suit your set up to correctly resolve client IP addresses:
|
||||
|
||||
* ``AXES_PROXY_COUNT``: The number of reverse proxies in front of Django as an integer. Default: ``None``
|
||||
* ``AXES_META_PRECEDENCE_ORDER``: The names of ``request.META`` attributes as a tuple of strings
|
||||
* ``AXES_IPWARE_PROXY_COUNT``: The number of reverse proxies in front of Django as an integer. Default: ``None``
|
||||
* ``AXES_IPWARE_META_PRECEDENCE_ORDER``: The names of ``request.META`` attributes as a tuple of strings
|
||||
to check to get the client IP address. Check the Django documentation for header naming conventions.
|
||||
Default: ``IPWARE_META_PRECEDENCE_ORDER`` setting if set, else ``('REMOTE_ADDR', )``
|
||||
|
||||
|
|
@ -149,9 +116,9 @@ following settings to suit your set up to correctly resolve client IP addresses:
|
|||
.. code-block:: python
|
||||
|
||||
# refer to the Django request and response objects documentation
|
||||
AXES_META_PRECEDENCE_ORDER = [
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'REMOTE_ADDR',
|
||||
AXES_IPWARE_META_PRECEDENCE_ORDER = [
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'REMOTE_ADDR',
|
||||
]
|
||||
|
||||
Please note that proxies have different behaviours with the HTTP headers. Make sure that your proxy either strips the incoming value or otherwise makes sure of the validity of the header that is used because **any header values used in application configuration must be secure and trusted**. Otherwise the client can spoof IP addresses by just setting the header in their request and circumvent the IP address monitoring. Normal proxy server behaviours include overriding and appending the header value depending on the platform. Different platforms and gateway services utilize different headers, please refer to your deployment target documentation for up-to-date information on correct configuration.
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ Here is a more detailed example of sending the necessary signals using
|
|||
and a custom auth backend at an endpoint that expects JSON
|
||||
requests. The custom authentication can be swapped out with ``authenticate``
|
||||
and ``login`` from ``django.contrib.auth``, but beware that those methods take
|
||||
care of sending the nessary signals for you, and there is no need to duplicate
|
||||
care of sending the necessary signals for you, and there is no need to duplicate
|
||||
them as per the example.
|
||||
|
||||
``example/forms.py``::
|
||||
|
|
@ -155,7 +155,6 @@ into ``my_namespace-username``:
|
|||
fine, but Axes does not inject these changes into the authentication flow
|
||||
for you.
|
||||
|
||||
|
||||
Customizing lockout responses
|
||||
-----------------------------
|
||||
|
||||
|
|
@ -173,3 +172,59 @@ An example of usage could be e.g. a custom view for processing lockouts.
|
|||
``settings.py``::
|
||||
|
||||
AXES_LOCKOUT_CALLABLE = "example.views.lockout"
|
||||
|
||||
.. _customizing-lockout-parameters:
|
||||
|
||||
Customizing lockout parameters
|
||||
------------------------------
|
||||
|
||||
Axes can be configured with ``AXES_LOCKOUT_PARAMETERS`` to lock out users not only by IP address.
|
||||
|
||||
``AXES_LOCKOUT_PARAMETERS`` can be a list of strings (which represents a separate lockout parameter) or nested lists of strings (which represents lockout parameters used in combination) or a callable which accepts HttpRequest or AccessAttempt and credentials and returns a list of the same form as described earlier.
|
||||
|
||||
Example ``AXES_LOCKOUT_PARAMETERS`` configuration:
|
||||
|
||||
``settings.py``::
|
||||
|
||||
AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]
|
||||
|
||||
This way, axes will lock out users using ip_address and/or combination of username and user agent
|
||||
|
||||
Example of callable ``AXES_LOCKOUT_PARAMETERS``:
|
||||
|
||||
``example/utils.py``::
|
||||
|
||||
from django.http import HttpRequest
|
||||
|
||||
def get_lockout_parameters(request_or_attempt, credentials):
|
||||
|
||||
if isinstance(request_or_attempt, HttpRequest):
|
||||
is_localhost = request.META.get("REMOTE_ADDR") == "127.0.0.1"
|
||||
|
||||
else:
|
||||
is_localhost = request_or_attempt.ip_address == "127.0.0.1"
|
||||
|
||||
if is_localhost:
|
||||
return ["username"]
|
||||
|
||||
return ["ip_address", "username"]
|
||||
|
||||
``settings.py``::
|
||||
|
||||
AXES_LOCKOUT_PARAMETERS = "example.utils.get_lockout_parameters"
|
||||
|
||||
This way, if client ip_address is localhost, axes will lockout client only by username. In other case, axes will lockout client by username and/or ip_address.
|
||||
|
||||
Customizing client ip address lookups
|
||||
-------------------------------------
|
||||
|
||||
Axes can be configured with ``AXES_CLIENT_IP_CALLABLE`` to use custom client ip address lookup logic.
|
||||
|
||||
``example/utils.py``::
|
||||
|
||||
def get_client_ip(request):
|
||||
return request.META.get("REMOTE_ADDR")
|
||||
|
||||
``settings.py``::
|
||||
|
||||
AXES_CLIENT_IP_CALLABLE = "example.utils.get_client_ip"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ Django Allauth |check|
|
|||
Django Simple Captcha |check|
|
||||
Django OAuth Toolkit |check|
|
||||
Django Reversion |check|
|
||||
Django Auth LDAP |check|
|
||||
======================= ============= ============ ============ ==============
|
||||
|
||||
.. |check| unicode:: U+2713
|
||||
|
|
@ -98,6 +99,11 @@ You also need to decorate ``dispatch()`` and ``form_invalid()`` methods of the A
|
|||
Integration with Django REST Framework
|
||||
--------------------------------------
|
||||
|
||||
.. warning::
|
||||
The following guide only covers authentication schemes that rely on
|
||||
Django's ``authenticate()`` function. Other schemes (e.g.
|
||||
``TokenAuthentication``) are currently not supported.
|
||||
|
||||
Django Axes requires REST Framework to be connected
|
||||
via lockout signals for correct functionality.
|
||||
|
||||
|
|
@ -126,7 +132,7 @@ And then configure your application to load it in ``examples/apps.py``::
|
|||
|
||||
Please check the Django signals documentation for more information:
|
||||
|
||||
https://docs.djangoproject.com/en/3.1/topics/signals/
|
||||
https://docs.djangoproject.com/en/3.2/topics/signals/
|
||||
|
||||
When a user login fails a signal is emitted and PermissionDenied
|
||||
raises a HTTP 403 reply which interrupts the login process.
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ are not blocked, and allows the requests to go through if the check passes.
|
|||
|
||||
If the authentication attempt matches a lockout rule, e.g. it is from a
|
||||
blacklisted IP or exceeds the maximum configured authentication attempts,
|
||||
it is blocked by raising the ``PermissionDenied`` excepton in the backend.
|
||||
it is blocked by raising the ``PermissionDenied`` exception in the backend.
|
||||
|
||||
Axes monitors logins with the ``user_login_failed`` signal receiver
|
||||
and records authentication failures from both the ``AxesBackend`` and
|
||||
|
|
|
|||
3
docs/9_contributing.rst
Normal file
3
docs/9_contributing.rst
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.. _contributing:
|
||||
|
||||
.. include:: ../CONTRIBUTING.rst
|
||||
|
|
@ -9,7 +9,7 @@ BUILDDIR = _build
|
|||
|
||||
# User-friendly check for sphinx-build
|
||||
ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
|
||||
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
|
||||
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from https://sphinx-doc.org/)
|
||||
endif
|
||||
|
||||
# Internal variables.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ Sphinx documentation generator configuration.
|
|||
|
||||
More information on the configuration options is available at:
|
||||
|
||||
http://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
"""
|
||||
|
||||
import sphinx_rtd_theme
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ Contents
|
|||
6_integration
|
||||
7_architecture
|
||||
8_reference
|
||||
9_development
|
||||
9_contributing
|
||||
10_changelog
|
||||
|
||||
|
||||
|
|
|
|||
2
mypy.ini
2
mypy.ini
|
|
@ -1,5 +1,5 @@
|
|||
[mypy]
|
||||
python_version = 3.6
|
||||
python_version = 3.8
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-axes.migrations.*]
|
||||
|
|
|
|||
|
|
@ -10,25 +10,26 @@ DJANGO_SETTINGS_MODULE = "tests.settings"
|
|||
legacy_tox_ini = """
|
||||
[tox]
|
||||
envlist =
|
||||
py{36,37,38,39,py3}-dj{22,31}
|
||||
py{36,37,38,39,310}-dj32
|
||||
py{38,39,310}-djmain
|
||||
py38-djqa
|
||||
py{38,39,310,py310}-dj32
|
||||
py{38,39,310,311,py310}-dj42
|
||||
py{310,311,py310}-dj50
|
||||
py311-djmain
|
||||
py311-djqa
|
||||
|
||||
[gh-actions]
|
||||
python =
|
||||
3.6: py36
|
||||
3.7: py37
|
||||
3.8: py38
|
||||
3.9: py39
|
||||
3.10: py310
|
||||
pypy3: pypy3
|
||||
3.11: py311
|
||||
3.12: py312
|
||||
pypy-3.10: pypy310
|
||||
|
||||
[gh-actions:env]
|
||||
DJANGO =
|
||||
2.2: dj22
|
||||
3.1: dj31
|
||||
3.2: dj32
|
||||
4.1: dj41
|
||||
4.2: dj42
|
||||
main: djmain
|
||||
qa: djqa
|
||||
|
||||
|
|
@ -36,25 +37,27 @@ DJANGO =
|
|||
[testenv]
|
||||
deps =
|
||||
-r requirements-test.txt
|
||||
dj22: django>=2.2,<2.3
|
||||
dj31: django>=3.1,<3.2
|
||||
dj32: django>=3.2,<3.3
|
||||
dj42: django>=4.1,<4.2
|
||||
dj50: django>=5.0,<5.1
|
||||
djmain: https://github.com/django/django/archive/main.tar.gz
|
||||
usedevelop = true
|
||||
commands = pytest
|
||||
setenv =
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# Django development version is allowed to fail the test matrix
|
||||
[testenv:py{38,39,py3}-djmain]
|
||||
ignore_errors = true
|
||||
ignore_outcome = true
|
||||
ignore_outcome =
|
||||
djmain: True
|
||||
pypy310: True
|
||||
ignore_errors =
|
||||
djmain: True
|
||||
pypy310: True
|
||||
|
||||
# QA runs type checks, linting, and code formatting checks
|
||||
[testenv:py38-djqa]
|
||||
[testenv:py312-djqa]
|
||||
deps = -r requirements-qa.txt
|
||||
commands =
|
||||
mypy axes
|
||||
prospector
|
||||
black -t py36 --check --diff axes
|
||||
black -t py38 --check --diff axes
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
black==21.10b0
|
||||
mypy==0.910
|
||||
prospector==1.3.1
|
||||
types-pkg_resources # Type stub
|
||||
black==24.4.0
|
||||
mypy==1.10.0
|
||||
prospector==1.10.3
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
-e .
|
||||
coverage==6.1.1
|
||||
pytest==6.2.5
|
||||
pytest-cov==3.0.0
|
||||
pytest-django==4.4.0
|
||||
pytest-subtests==0.5.0
|
||||
django-ipware>=3
|
||||
coverage==7.4.4
|
||||
pytest==8.2.0
|
||||
pytest-cov==5.0.0
|
||||
pytest-django==4.8.0
|
||||
pytest-subtests==0.12.1
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
-e .
|
||||
-r requirements-qa.txt
|
||||
-r requirements-test.txt
|
||||
sphinx_rtd_theme==1.0.0
|
||||
tox==3.24.4
|
||||
sphinx_rtd_theme==2.0.0
|
||||
tox==4.15.0
|
||||
|
|
|
|||
18
setup.py
18
setup.py
|
|
@ -35,8 +35,14 @@ setup(
|
|||
package_dir={"axes": "axes"},
|
||||
use_scm_version=True,
|
||||
setup_requires=["setuptools_scm"],
|
||||
python_requires="~=3.6",
|
||||
install_requires=["django>=2.2", "django-ipware>=3,<5", "setuptools"],
|
||||
python_requires=">=3.7",
|
||||
install_requires=[
|
||||
"django>=3.2",
|
||||
"asgiref>=3.6.0",
|
||||
],
|
||||
extras_require={
|
||||
"ipware": "django-ipware>=3",
|
||||
},
|
||||
include_package_data=True,
|
||||
packages=find_packages(exclude=["tests"]),
|
||||
classifiers=[
|
||||
|
|
@ -44,20 +50,20 @@ setup(
|
|||
"Environment :: Web Environment",
|
||||
"Environment :: Plugins",
|
||||
"Framework :: Django",
|
||||
"Framework :: Django :: 2.2",
|
||||
"Framework :: Django :: 3.1",
|
||||
"Framework :: Django :: 3.2",
|
||||
"Framework :: Django :: 4.2",
|
||||
"Framework :: Django :: 5.0",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: System Administrators",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Programming Language :: Python :: Implementation :: PyPy",
|
||||
"Topic :: Internet :: Log Analysis",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from axes.helpers import (
|
|||
get_credentials,
|
||||
get_failure_limit,
|
||||
)
|
||||
from axes.models import AccessAttempt, AccessLog
|
||||
from axes.models import AccessAttempt, AccessLog, AccessFailureLog
|
||||
from axes.utils import reset
|
||||
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ class AxesTestCase(TestCase):
|
|||
|
||||
STATUS_SUCCESS = 200
|
||||
ALLOWED = 302
|
||||
BLOCKED = 403
|
||||
BLOCKED = 429
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
|
|
@ -79,6 +79,7 @@ class AxesTestCase(TestCase):
|
|||
self.request.axes_path_info = get_client_path_info(self.request)
|
||||
self.request.axes_http_accept = get_client_http_accept(self.request)
|
||||
self.request.axes_failures_since_start = None
|
||||
self.request.axes_locked_out = False
|
||||
|
||||
self.credentials = get_credentials(self.username)
|
||||
|
||||
|
|
@ -103,6 +104,9 @@ class AxesTestCase(TestCase):
|
|||
def create_log(self, **kwargs):
|
||||
return AccessLog.objects.create(**self.get_kwargs_with_defaults(**kwargs))
|
||||
|
||||
def create_failure_log(self, **kwargs):
|
||||
return AccessFailureLog.objects.create(**self.get_kwargs_with_defaults(**kwargs))
|
||||
|
||||
def reset(self, ip=None, username=None):
|
||||
return reset(ip, username)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,10 +20,13 @@ MIDDLEWARE = [
|
|||
]
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"axes.backends.AxesBackend",
|
||||
"axes.backends.AxesStandaloneBackend",
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
]
|
||||
|
||||
# Use MD5 for tests as it is considerably faster than other options
|
||||
# note that this should never be used in any online setting
|
||||
# where users actually log in to the system due to easy exploitability
|
||||
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
|
||||
|
||||
ROOT_URLCONF = "tests.urls"
|
||||
|
|
@ -65,8 +68,6 @@ SECRET_KEY = "too-secret-for-test"
|
|||
|
||||
USE_I18N = False
|
||||
|
||||
USE_L10N = False
|
||||
|
||||
USE_TZ = False
|
||||
|
||||
LOGIN_REDIRECT_URL = "/admin/"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from django.contrib import admin
|
|||
from django.test import override_settings
|
||||
|
||||
import axes.admin
|
||||
from axes.models import AccessAttempt, AccessLog
|
||||
from axes.models import AccessAttempt, AccessLog, AccessFailureLog
|
||||
from tests.base import AxesTestCase
|
||||
|
||||
|
||||
|
|
@ -15,14 +15,18 @@ class AxesEnableAdminFlag(AxesTestCase):
|
|||
admin.site.unregister(AccessAttempt)
|
||||
with suppress(admin.sites.NotRegistered):
|
||||
admin.site.unregister(AccessLog)
|
||||
with suppress(admin.sites.NotRegistered):
|
||||
admin.site.unregister(AccessFailureLog)
|
||||
|
||||
@override_settings(AXES_ENABLE_ADMIN=False)
|
||||
def test_disable_admin(self):
|
||||
reload(axes.admin)
|
||||
self.assertFalse(admin.site.is_registered(AccessAttempt))
|
||||
self.assertFalse(admin.site.is_registered(AccessLog))
|
||||
self.assertFalse(admin.site.is_registered(AccessFailureLog))
|
||||
|
||||
def test_enable_admin_by_default(self):
|
||||
reload(axes.admin)
|
||||
self.assertTrue(admin.site.is_registered(AccessAttempt))
|
||||
self.assertTrue(admin.site.is_registered(AccessLog))
|
||||
self.assertTrue(admin.site.is_registered(AccessFailureLog))
|
||||
|
|
|
|||
|
|
@ -82,74 +82,74 @@ class ResetResponseTestCase(AxesTestCase):
|
|||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 3)
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
def test_reset_user_failures(self):
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 5)
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
def test_reset_ip_user_failures(self):
|
||||
self.request.META["REMOTE_ADDR"] = self.IP_1
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 5)
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
def test_reset_username_user_failures(self):
|
||||
self.request.GET["username"] = self.USERNAME_1
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 3)
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
def test_reset_ip_username_user_failures(self):
|
||||
self.request.GET["username"] = self.USERNAME_1
|
||||
self.request.META["REMOTE_ADDR"] = self.IP_1
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 3)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
def test_reset_user_or_ip(self):
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 5)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
def test_reset_ip_user_or_ip(self):
|
||||
self.request.META["REMOTE_ADDR"] = self.IP_1
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 3)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
def test_reset_username_user_or_ip(self):
|
||||
self.request.GET["username"] = self.USERNAME_1
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 3)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
def test_reset_ip_username_user_or_ip(self):
|
||||
self.request.GET["username"] = self.USERNAME_1
|
||||
self.request.META["REMOTE_ADDR"] = self.IP_1
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 2)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
def test_reset_user_and_ip(self):
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 5)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
def test_reset_ip_user_and_ip(self):
|
||||
self.request.META["REMOTE_ADDR"] = self.IP_1
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 3)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
def test_reset_username_user_and_ip(self):
|
||||
self.request.GET["username"] = self.USERNAME_1
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 3)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_AND=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
def test_reset_ip_username_user_and_ip(self):
|
||||
self.request.GET["username"] = self.USERNAME_1
|
||||
self.request.META["REMOTE_ADDR"] = self.IP_1
|
||||
reset_request(self.request)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 3)
|
||||
self.assertEqual(AccessAttempt.objects.count(), 4)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from django.core.checks import run_checks, Warning # pylint: disable=redefined-builtin
|
||||
from django.test import override_settings, modify_settings
|
||||
|
||||
from axes.backends import AxesBackend
|
||||
from axes.backends import AxesStandaloneBackend
|
||||
from axes.checks import Messages, Hints, Codes
|
||||
from tests.base import AxesTestCase
|
||||
|
||||
|
|
@ -58,12 +58,14 @@ class MiddlewareCheckTestCase(AxesTestCase):
|
|||
self.assertEqual(warnings, [warning])
|
||||
|
||||
|
||||
class AxesSpecializedBackend(AxesBackend):
|
||||
class AxesSpecializedBackend(AxesStandaloneBackend):
|
||||
pass
|
||||
|
||||
|
||||
class BackendCheckTestCase(AxesTestCase):
|
||||
@modify_settings(AUTHENTICATION_BACKENDS={"remove": ["axes.backends.AxesBackend"]})
|
||||
@modify_settings(
|
||||
AUTHENTICATION_BACKENDS={"remove": ["axes.backends.AxesStandaloneBackend"]}
|
||||
)
|
||||
def test_backend_missing(self):
|
||||
warnings = run_checks()
|
||||
warning = Warning(
|
||||
|
|
@ -108,3 +110,22 @@ class DeprecatedSettingsTestCase(AxesTestCase):
|
|||
def test_deprecated_success_access_log_flag(self):
|
||||
warnings = run_checks()
|
||||
self.assertEqual(warnings, [self.disable_success_access_log_warning])
|
||||
|
||||
|
||||
class ConfCheckTestCase(AxesTestCase):
|
||||
@override_settings(AXES_USERNAME_CALLABLE="module.not_defined")
|
||||
def test_invalid_import_path(self):
|
||||
warnings = run_checks()
|
||||
warning = Warning(
|
||||
msg=Messages.CALLABLE_INVALID.format(
|
||||
callable_setting="AXES_USERNAME_CALLABLE"
|
||||
),
|
||||
hint=Hints.CALLABLE_INVALID,
|
||||
id=Codes.CALLABLE_INVALID,
|
||||
)
|
||||
self.assertEqual(warnings, [warning])
|
||||
|
||||
@override_settings(AXES_COOLOFF_TIME=lambda: 1)
|
||||
def test_valid_callable(self):
|
||||
warnings = run_checks()
|
||||
self.assertEqual(warnings, [])
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from tests.base import AxesTestCase
|
|||
|
||||
class DecoratorTestCase(AxesTestCase):
|
||||
SUCCESS_RESPONSE = HttpResponse(status=200, content="Dispatched")
|
||||
LOCKOUT_RESPONSE = HttpResponse(status=403, content="Locked out")
|
||||
LOCKOUT_RESPONSE = HttpResponse(status=429, content="Locked out")
|
||||
|
||||
def setUp(self):
|
||||
self.request = MagicMock()
|
||||
|
|
|
|||
16
tests/test_failures.py
Normal file
16
tests/test_failures.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from axes.models import AccessFailureLog
|
||||
from tests.base import AxesTestCase
|
||||
from axes.helpers import get_failure_limit
|
||||
from django.test import override_settings
|
||||
|
||||
@override_settings(AXES_ENABLE_ACCESS_FAILURE_LOG=True)
|
||||
class FailureLogTestCase(AxesTestCase):
|
||||
def test_failure_log(self):
|
||||
self.login(is_valid_username=True, is_valid_password=False)
|
||||
self.assertEqual(AccessFailureLog.objects.count(), 1)
|
||||
self.assertTrue(AccessFailureLog.objects.filter(username=self.VALID_USERNAME).exists())
|
||||
self.assertTrue(AccessFailureLog.objects.filter(ip_address=self.ip_address).exists())
|
||||
|
||||
def test_failure_locked_out(self):
|
||||
self.check_lockout()
|
||||
self.assertEqual(AccessFailureLog.objects.filter(locked_out=True).count(), 1)
|
||||
|
|
@ -12,7 +12,7 @@ from django.utils.timezone import timedelta
|
|||
from axes.conf import settings
|
||||
from axes.handlers.proxy import AxesProxyHandler
|
||||
from axes.helpers import get_client_str
|
||||
from axes.models import AccessAttempt, AccessLog
|
||||
from axes.models import AccessAttempt, AccessLog, AccessFailureLog
|
||||
from tests.base import AxesTestCase
|
||||
|
||||
|
||||
|
|
@ -55,14 +55,36 @@ class AxesHandlerTestCase(AxesTestCase):
|
|||
for setting_value, url, expected in tests:
|
||||
with override_settings(AXES_ONLY_ADMIN_SITE=setting_value):
|
||||
request.path = url
|
||||
self.assertEqual(AxesProxyHandler().is_admin_site(request), expected)
|
||||
with self.assertWarns(DeprecationWarning):
|
||||
self.assertEqual(AxesProxyHandler().is_admin_site(request), expected)
|
||||
|
||||
def test_is_admin_request(self):
|
||||
request = MagicMock()
|
||||
tests = ( # (URL, Expected)
|
||||
("/test/", False),
|
||||
(reverse("admin:index"), True),
|
||||
)
|
||||
|
||||
for url, expected in tests:
|
||||
request.path = url
|
||||
self.assertEqual(AxesProxyHandler().is_admin_request(request), expected)
|
||||
|
||||
@override_settings(ROOT_URLCONF="tests.urls_empty")
|
||||
@override_settings(AXES_ONLY_ADMIN_SITE=True)
|
||||
def test_is_admin_site_no_admin_site(self):
|
||||
request = MagicMock()
|
||||
request.path = "/admin/"
|
||||
self.assertTrue(AxesProxyHandler().is_admin_site(self.request))
|
||||
with self.assertWarns(DeprecationWarning):
|
||||
self.assertTrue(AxesProxyHandler().is_admin_site(self.request))
|
||||
|
||||
@override_settings(ROOT_URLCONF="tests.urls_empty")
|
||||
def test_is_admin_request_no_admin_site(self):
|
||||
request = MagicMock()
|
||||
request.path = "/admin/"
|
||||
self.assertFalse(AxesProxyHandler().is_admin_request(self.request))
|
||||
|
||||
def test_is_admin_request_no_path(self):
|
||||
self.assertFalse(AxesProxyHandler().is_admin_request(self.request))
|
||||
|
||||
|
||||
class AxesProxyHandlerTestCase(AxesTestCase):
|
||||
|
|
@ -212,6 +234,7 @@ class ResetAttemptsTestCase(AxesHandlerBaseTestCase):
|
|||
AXES_HANDLER="axes.handlers.database.AxesDatabaseHandler",
|
||||
AXES_COOLOFF_TIME=timedelta(seconds=2),
|
||||
AXES_RESET_ON_SUCCESS=True,
|
||||
AXES_ENABLE_ACCESS_FAILURE_LOG=True,
|
||||
)
|
||||
@mark.xfail(
|
||||
python_implementation() == "PyPy",
|
||||
|
|
@ -240,6 +263,31 @@ class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase):
|
|||
self.assertEqual(1, AxesProxyHandler.reset_logs(age_days=42))
|
||||
self.assertEqual(AccessLog.objects.count(), 1)
|
||||
|
||||
def test_handler_reset_failure_logs(self):
|
||||
self.create_failure_log()
|
||||
self.assertEqual(1, AxesProxyHandler.reset_failure_logs())
|
||||
self.assertFalse(AccessFailureLog.objects.count())
|
||||
|
||||
def test_handler_reset_failure_logs_older_than_42_days(self):
|
||||
self.create_failure_log()
|
||||
|
||||
then = timezone.now() - timezone.timedelta(days=90)
|
||||
with patch("django.utils.timezone.now", return_value=then):
|
||||
self.create_failure_log()
|
||||
|
||||
self.assertEqual(AccessFailureLog.objects.count(), 2)
|
||||
self.assertEqual(1, AxesProxyHandler.reset_failure_logs(age_days=42))
|
||||
self.assertEqual(AccessFailureLog.objects.count(), 1)
|
||||
|
||||
def test_handler_remove_out_of_limit_failure_logs(self):
|
||||
_more = 10
|
||||
for i in range(settings.AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT + _more):
|
||||
self.create_failure_log()
|
||||
self.assertEqual(
|
||||
_more,
|
||||
AxesProxyHandler.remove_out_of_limit_failure_logs(username=self.username),
|
||||
)
|
||||
|
||||
@override_settings(AXES_RESET_ON_SUCCESS=True)
|
||||
def test_handler(self):
|
||||
self.check_handler()
|
||||
|
|
@ -273,7 +321,7 @@ class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase):
|
|||
def test_whitelist(self, log):
|
||||
self.check_whitelist(log)
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@patch("axes.handlers.database.log")
|
||||
def test_user_login_failed_only_user_failures_with_none_username(self, log):
|
||||
credentials = {"username": None, "password": "test"}
|
||||
|
|
@ -282,7 +330,7 @@ class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase):
|
|||
attempt = AccessAttempt.objects.all()
|
||||
self.assertEqual(0, AccessAttempt.objects.count())
|
||||
log.warning.assert_called_with(
|
||||
"AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created."
|
||||
"AXES: Username is None and username is the only one lockout parameter, new record will NOT be created."
|
||||
)
|
||||
|
||||
def test_user_login_failed_with_none_username(self):
|
||||
|
|
@ -295,22 +343,37 @@ class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase):
|
|||
def test_user_login_failed_multiple_username(self):
|
||||
configurations = (
|
||||
(2, 1, {}, ["admin", "admin1"]),
|
||||
(2, 1, {"AXES_USE_USER_AGENT": True}, ["admin", "admin1"]),
|
||||
(2, 1, {"AXES_ONLY_USER_FAILURES": True}, ["admin", "admin1"]),
|
||||
(
|
||||
2,
|
||||
1,
|
||||
{"AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP": True},
|
||||
{"AXES_LOCKOUT_PARAMETERS": [["ip_address", "user_agent"]]},
|
||||
["admin", "admin1"],
|
||||
),
|
||||
(2, 1, {"AXES_LOCKOUT_PARAMETERS": ["username"]}, ["admin", "admin1"]),
|
||||
(
|
||||
2,
|
||||
1,
|
||||
{"AXES_LOCKOUT_PARAMETERS": [["username", "ip_address"]]},
|
||||
["admin", "admin1"],
|
||||
),
|
||||
(
|
||||
1,
|
||||
2,
|
||||
{"AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP": True},
|
||||
{"AXES_LOCKOUT_PARAMETERS": [["username", "ip_address"]]},
|
||||
["admin", "admin"],
|
||||
),
|
||||
(1, 2, {"AXES_LOCK_OUT_BY_USER_OR_IP": True}, ["admin", "admin"]),
|
||||
(2, 1, {"AXES_LOCK_OUT_BY_USER_OR_IP": True}, ["admin", "admin1"]),
|
||||
(
|
||||
1,
|
||||
2,
|
||||
{"AXES_LOCKOUT_PARAMETERS": ["username", "ip_address"]},
|
||||
["admin", "admin"],
|
||||
),
|
||||
(
|
||||
2,
|
||||
1,
|
||||
{"AXES_LOCKOUT_PARAMETERS": ["username", "ip_address"]},
|
||||
["admin", "admin1"],
|
||||
),
|
||||
)
|
||||
|
||||
for (
|
||||
|
|
@ -377,7 +440,7 @@ class ResetAttemptsCacheHandlerTestCase(AxesHandlerBaseTestCase):
|
|||
with self.assertRaises(NotImplementedError):
|
||||
AxesProxyHandler.reset_attempts()
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
def test_handler_reset_attempts_username(self):
|
||||
self.set_up_login_attempts()
|
||||
self.assertEqual(
|
||||
|
|
@ -413,7 +476,7 @@ class ResetAttemptsCacheHandlerTestCase(AxesHandlerBaseTestCase):
|
|||
self.check_failures(0, ip_address=self.IP_1)
|
||||
self.check_failures(2, ip_address=self.IP_2)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
def test_handler_reset_attempts_ip_and_username(self):
|
||||
self.set_up_login_attempts()
|
||||
self.check_failures(1, username=self.USERNAME_1, ip_address=self.IP_1)
|
||||
|
|
@ -459,7 +522,7 @@ class AxesCacheHandlerTestCase(AxesHandlerBaseTestCase):
|
|||
def test_whitelist(self, log):
|
||||
self.check_whitelist(log)
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@patch.object(cache, "set")
|
||||
@patch("axes.handlers.cache.log")
|
||||
def test_user_login_failed_only_user_failures_with_none_username(
|
||||
|
|
@ -470,15 +533,15 @@ class AxesCacheHandlerTestCase(AxesHandlerBaseTestCase):
|
|||
AxesProxyHandler.user_login_failed(sender, credentials, self.request)
|
||||
self.assertFalse(cache_set.called)
|
||||
log.warning.assert_called_with(
|
||||
"AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created."
|
||||
"AXES: Username is None and username is the only one lockout parameter, new record will NOT be created."
|
||||
)
|
||||
|
||||
@patch.object(cache, "set")
|
||||
def test_user_login_failed_with_none_username(self, cache_set):
|
||||
@patch.object(cache, "add")
|
||||
def test_user_login_failed_with_none_username(self, cache_add):
|
||||
credentials = {"username": None, "password": "test"}
|
||||
sender = MagicMock()
|
||||
AxesProxyHandler.user_login_failed(sender, credentials, self.request)
|
||||
self.assertTrue(cache_set.called)
|
||||
self.assertTrue(cache_add.called)
|
||||
|
||||
|
||||
@override_settings(AXES_HANDLER="axes.handlers.dummy.AxesDummyHandler")
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
from datetime import timedelta
|
||||
from hashlib import md5
|
||||
from hashlib import sha256
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.http import JsonResponse, HttpResponseRedirect, HttpResponse, HttpRequest
|
||||
from django.test import override_settings, RequestFactory
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse
|
||||
from django.test import RequestFactory, override_settings
|
||||
|
||||
from axes.apps import AppConfig
|
||||
from axes.helpers import (
|
||||
cleanse_parameters,
|
||||
get_cache_timeout,
|
||||
get_client_cache_keys,
|
||||
get_client_ip_address,
|
||||
get_client_parameters,
|
||||
get_client_str,
|
||||
get_client_username,
|
||||
get_client_cache_key,
|
||||
get_client_parameters,
|
||||
get_cool_off,
|
||||
get_cool_off_iso8601,
|
||||
get_lockout_response,
|
||||
|
|
@ -23,7 +25,6 @@ from axes.helpers import (
|
|||
is_ip_address_in_whitelist,
|
||||
is_user_attempt_whitelisted,
|
||||
toggleable,
|
||||
cleanse_parameters,
|
||||
)
|
||||
from axes.models import AccessAttempt
|
||||
from tests.base import AxesTestCase
|
||||
|
|
@ -81,6 +82,7 @@ class TimestampTestCase(AxesTestCase):
|
|||
self.assertEqual(get_cool_off_iso8601(delta), iso_duration)
|
||||
|
||||
|
||||
@override_settings(AXES_SENSITIVE_PARAMETERS=[])
|
||||
class ClientStringTestCase(AxesTestCase):
|
||||
@staticmethod
|
||||
def get_expected_client_str(*args, **kwargs):
|
||||
|
|
@ -149,7 +151,7 @@ class ClientStringTestCase(AxesTestCase):
|
|||
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_VERBOSE=True)
|
||||
def test_verbose_user_only_client_details(self):
|
||||
username = "test@example.com"
|
||||
|
|
@ -166,7 +168,7 @@ class ClientStringTestCase(AxesTestCase):
|
|||
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
@override_settings(AXES_VERBOSE=False)
|
||||
def test_non_verbose_user_only_client_details(self):
|
||||
username = "test@example.com"
|
||||
|
|
@ -181,7 +183,7 @@ class ClientStringTestCase(AxesTestCase):
|
|||
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_VERBOSE=True)
|
||||
def test_verbose_user_ip_combo_client_details(self):
|
||||
username = "test@example.com"
|
||||
|
|
@ -198,7 +200,7 @@ class ClientStringTestCase(AxesTestCase):
|
|||
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
@override_settings(AXES_VERBOSE=False)
|
||||
def test_non_verbose_user_ip_combo_client_details(self):
|
||||
username = "test@example.com"
|
||||
|
|
@ -213,7 +215,7 @@ class ClientStringTestCase(AxesTestCase):
|
|||
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
@override_settings(AXES_USE_USER_AGENT=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "user_agent"]])
|
||||
@override_settings(AXES_VERBOSE=True)
|
||||
def test_verbose_user_agent_client_details(self):
|
||||
username = "test@example.com"
|
||||
|
|
@ -230,7 +232,7 @@ class ClientStringTestCase(AxesTestCase):
|
|||
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
@override_settings(AXES_USE_USER_AGENT=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "user_agent"]])
|
||||
@override_settings(AXES_VERBOSE=False)
|
||||
def test_non_verbose_user_agent_client_details(self):
|
||||
username = "test@example.com"
|
||||
|
|
@ -248,7 +250,7 @@ class ClientStringTestCase(AxesTestCase):
|
|||
@override_settings(
|
||||
AXES_CLIENT_STR_CALLABLE="tests.test_helpers.get_dummy_client_str"
|
||||
)
|
||||
def test_get_client_str_callable(self):
|
||||
def test_get_client_str_callable_return_str(self):
|
||||
self.assertEqual(
|
||||
get_client_str(
|
||||
"username", "ip_address", "user_agent", "path_info", self.request
|
||||
|
|
@ -259,7 +261,7 @@ class ClientStringTestCase(AxesTestCase):
|
|||
@override_settings(
|
||||
AXES_CLIENT_STR_CALLABLE="tests.test_helpers.get_dummy_client_str_using_request"
|
||||
)
|
||||
def test_get_client_str_callable(self):
|
||||
def test_get_client_str_callable_using_request(self):
|
||||
self.request.user = self.user
|
||||
self.assertEqual(
|
||||
get_client_str(
|
||||
|
|
@ -268,8 +270,28 @@ class ClientStringTestCase(AxesTestCase):
|
|||
self.email,
|
||||
)
|
||||
|
||||
@override_settings(AXES_SENSITIVE_PARAMETERS=["username"])
|
||||
def test_get_client_str_with_sensitive_parameters(self):
|
||||
username = "test@example.com"
|
||||
ip_address = "127.0.0.1"
|
||||
user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
|
||||
path_info = "/admin/"
|
||||
|
||||
def get_dummy_client_str(username, ip_address, user_agent, path_info):
|
||||
expected = self.get_expected_client_str(
|
||||
"********************",
|
||||
ip_address,
|
||||
user_agent,
|
||||
path_info,
|
||||
self.request
|
||||
)
|
||||
actual = get_client_str(
|
||||
username, ip_address, user_agent, path_info, self.request
|
||||
)
|
||||
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
|
||||
def get_dummy_client_str(username, ip_address, user_agent, path_info, request):
|
||||
return "client string"
|
||||
|
||||
|
||||
|
|
@ -279,81 +301,270 @@ def get_dummy_client_str_using_request(
|
|||
return f"{request.user.email}"
|
||||
|
||||
|
||||
def get_dummy_lockout_parameters(request, credentials=None):
|
||||
return ["ip_address", ["username", "user_agent"]]
|
||||
|
||||
|
||||
class ClientParametersTestCase(AxesTestCase):
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
def test_get_filter_kwargs_user(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent),
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
[{"username": self.username}],
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
AXES_ONLY_USER_FAILURES=False,
|
||||
AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=False,
|
||||
AXES_USE_USER_AGENT=False,
|
||||
)
|
||||
def test_get_filter_kwargs_ip(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent),
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
[{"ip_address": self.ip_address}],
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
AXES_ONLY_USER_FAILURES=False,
|
||||
AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True,
|
||||
AXES_USE_USER_AGENT=False,
|
||||
)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
def test_get_filter_kwargs_user_and_ip(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent),
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
[{"username": self.username, "ip_address": self.ip_address}],
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
AXES_ONLY_USER_FAILURES=False,
|
||||
AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=False,
|
||||
AXES_LOCK_OUT_BY_USER_OR_IP=True,
|
||||
AXES_USE_USER_AGENT=False,
|
||||
)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "user_agent"]])
|
||||
def test_get_filter_kwargs_user_and_user_agent(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
[{"username": self.username, "user_agent": self.user_agent}],
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["ip_address", ["username", "user_agent"]])
|
||||
def test_get_filter_kwargs_ip_or_user_and_user_agent(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
[{"ip_address": self.ip_address}, {"username": self.username, "user_agent": self.user_agent}],
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "user_agent"], ["username", "user_agent"]])
|
||||
def test_get_filter_kwargs_ip_and_user_agent_or_user_and_user_agent(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
[{"ip_address": self.ip_address, "user_agent": self.user_agent}, {"username": self.username, "user_agent": self.user_agent}],
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
def test_get_filter_kwargs_user_or_ip(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent),
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
[{"username": self.username}, {"ip_address": self.ip_address}],
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
AXES_ONLY_USER_FAILURES=False,
|
||||
AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=False,
|
||||
AXES_USE_USER_AGENT=True,
|
||||
)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address", "user_agent"])
|
||||
def test_get_filter_kwargs_user_or_ip_or_user_agent(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
[{"username": self.username}, {"ip_address": self.ip_address}, {"user_agent": self.user_agent}],
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "user_agent"]])
|
||||
def test_get_filter_kwargs_ip_and_agent(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent),
|
||||
[{"ip_address": self.ip_address}, {"user_agent": self.user_agent}],
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
[{"ip_address": self.ip_address, "user_agent": self.user_agent}],
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
AXES_ONLY_USER_FAILURES=False,
|
||||
AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True,
|
||||
AXES_USE_USER_AGENT=True,
|
||||
AXES_LOCKOUT_PARAMETERS=[["username", "ip_address", "user_agent"]]
|
||||
)
|
||||
def test_get_filter_kwargs_user_ip_agent(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent),
|
||||
get_client_parameters(self.username, self.ip_address, self.user_agent, self.request, self.credentials),
|
||||
[
|
||||
{"username": self.username, "ip_address": self.ip_address},
|
||||
{"user_agent": self.user_agent},
|
||||
{
|
||||
"username": self.username,
|
||||
"ip_address": self.ip_address,
|
||||
"user_agent": self.user_agent,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["wrong_param"])
|
||||
@patch("axes.helpers.log")
|
||||
def test_get_filter_kwargs_invalid_parameter(self, log):
|
||||
with self.assertRaises(ValueError):
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
)
|
||||
log.exception.assert_called_with(
|
||||
(
|
||||
"wrong_param lockout parameter is not allowed. "
|
||||
"Allowed lockout parameters: username, ip_address, user_agent"
|
||||
)
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "wrong_param"]])
|
||||
@patch("axes.helpers.log")
|
||||
def test_get_filter_kwargs_invalid_combined_parameter(self, log):
|
||||
with self.assertRaises(ValueError):
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
)
|
||||
log.exception.assert_called_with(
|
||||
(
|
||||
"wrong_param lockout parameter is not allowed. "
|
||||
"Allowed lockout parameters: username, ip_address, user_agent"
|
||||
)
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=get_dummy_lockout_parameters)
|
||||
def test_get_filter_kwargs_callable_lockout_parameters(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
),
|
||||
[
|
||||
{
|
||||
"ip_address": self.ip_address,
|
||||
},
|
||||
{
|
||||
"username": self.username,
|
||||
"user_agent": self.user_agent,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
AXES_LOCKOUT_PARAMETERS="tests.test_helpers.get_dummy_lockout_parameters"
|
||||
)
|
||||
def test_get_filter_kwargs_callable_str_lockout_parameters(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
),
|
||||
[
|
||||
{
|
||||
"ip_address": self.ip_address,
|
||||
},
|
||||
{
|
||||
"username": self.username,
|
||||
"user_agent": self.user_agent,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
AXES_LOCKOUT_PARAMETERS=lambda request, credentials: ["username"]
|
||||
)
|
||||
def test_get_filter_kwargs_callable_lambda_lockout_parameters(self):
|
||||
self.assertEqual(
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
),
|
||||
[
|
||||
{
|
||||
"username": self.username,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=True)
|
||||
def test_get_filter_kwargs_not_list_or_callable(self):
|
||||
with self.assertRaises(TypeError):
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=lambda: None)
|
||||
def test_get_filter_kwargs_invalid_callable_too_few_arguments(self):
|
||||
with self.assertRaises(TypeError):
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=lambda request, credentials, extra: None)
|
||||
def test_get_filter_kwargs_invalid_callable_too_many_arguments(self):
|
||||
with self.assertRaises(TypeError):
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
AXES_LOCKOUT_PARAMETERS=lambda request, credentials: ["wrong_param"]
|
||||
)
|
||||
@patch("axes.helpers.log")
|
||||
def test_get_filter_kwargs_callable_invalid_lockout_param(self, log):
|
||||
with self.assertRaises(ValueError):
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
)
|
||||
log.exception.assert_called_with(
|
||||
(
|
||||
"wrong_param lockout parameter is not allowed. "
|
||||
"Allowed lockout parameters: username, ip_address, user_agent"
|
||||
)
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
AXES_LOCKOUT_PARAMETERS=lambda request, credentials: [
|
||||
["ip_address", "wrong_param"]
|
||||
]
|
||||
)
|
||||
@patch("axes.helpers.log")
|
||||
def test_get_filter_kwargs_callable_invalid_combined_lockout_param(self, log):
|
||||
with self.assertRaises(ValueError):
|
||||
get_client_parameters(
|
||||
self.username,
|
||||
self.ip_address,
|
||||
self.user_agent,
|
||||
self.request,
|
||||
self.credentials,
|
||||
)
|
||||
log.exception.assert_called_with(
|
||||
(
|
||||
"wrong_param lockout parameter is not allowed. "
|
||||
"Allowed lockout parameters: username, ip_address, user_agent"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ClientCacheKeyTestCase(AxesTestCase):
|
||||
def test_get_cache_key(self):
|
||||
def test_get_cache_keys(self):
|
||||
"""
|
||||
Test the cache key format.
|
||||
"""
|
||||
|
||||
cache_hash_digest = md5(self.ip_address.encode()).hexdigest()
|
||||
cache_hash_digest = sha256(self.ip_address.encode()).hexdigest()
|
||||
cache_hash_key = f"axes-{cache_hash_digest}"
|
||||
|
||||
# Getting cache key from request
|
||||
|
|
@ -362,7 +573,7 @@ class ClientCacheKeyTestCase(AxesTestCase):
|
|||
"/admin/login/", data={"username": self.username, "password": "test"}
|
||||
)
|
||||
|
||||
self.assertEqual([cache_hash_key], get_client_cache_key(request))
|
||||
self.assertEqual([cache_hash_key], get_client_cache_keys(request))
|
||||
|
||||
# Getting cache key from AccessAttempt Object
|
||||
attempt = AccessAttempt(
|
||||
|
|
@ -376,7 +587,7 @@ class ClientCacheKeyTestCase(AxesTestCase):
|
|||
failures_since_start=0,
|
||||
)
|
||||
|
||||
self.assertEqual([cache_hash_key], get_client_cache_key(attempt))
|
||||
self.assertEqual([cache_hash_key], get_client_cache_keys(attempt))
|
||||
|
||||
def test_get_cache_key_empty_ip_address(self):
|
||||
"""
|
||||
|
|
@ -385,7 +596,7 @@ class ClientCacheKeyTestCase(AxesTestCase):
|
|||
|
||||
empty_ip_address = ""
|
||||
|
||||
cache_hash_digest = md5(empty_ip_address.encode()).hexdigest()
|
||||
cache_hash_digest = sha256(empty_ip_address.encode()).hexdigest()
|
||||
cache_hash_key = f"axes-{cache_hash_digest}"
|
||||
|
||||
# Getting cache key from request
|
||||
|
|
@ -396,7 +607,7 @@ class ClientCacheKeyTestCase(AxesTestCase):
|
|||
REMOTE_ADDR=empty_ip_address,
|
||||
)
|
||||
|
||||
self.assertEqual([cache_hash_key], get_client_cache_key(request))
|
||||
self.assertEqual([cache_hash_key], get_client_cache_keys(request))
|
||||
|
||||
# Getting cache key from AccessAttempt Object
|
||||
attempt = AccessAttempt(
|
||||
|
|
@ -410,7 +621,7 @@ class ClientCacheKeyTestCase(AxesTestCase):
|
|||
failures_since_start=0,
|
||||
)
|
||||
|
||||
self.assertEqual([cache_hash_key], get_client_cache_key(attempt))
|
||||
self.assertEqual([cache_hash_key], get_client_cache_keys(attempt))
|
||||
|
||||
def test_get_cache_key_credentials(self):
|
||||
"""
|
||||
|
|
@ -418,7 +629,7 @@ class ClientCacheKeyTestCase(AxesTestCase):
|
|||
"""
|
||||
|
||||
ip_address = self.ip_address
|
||||
cache_hash_digest = md5(ip_address.encode()).hexdigest()
|
||||
cache_hash_digest = sha256(ip_address.encode()).hexdigest()
|
||||
cache_hash_key = f"axes-{cache_hash_digest}"
|
||||
|
||||
# Getting cache key from request
|
||||
|
|
@ -430,7 +641,7 @@ class ClientCacheKeyTestCase(AxesTestCase):
|
|||
# Difference between the upper test: new call signature with credentials
|
||||
credentials = {"username": self.username}
|
||||
|
||||
self.assertEqual([cache_hash_key], get_client_cache_key(request, credentials))
|
||||
self.assertEqual([cache_hash_key], get_client_cache_keys(request, credentials))
|
||||
|
||||
# Getting cache key from AccessAttempt Object
|
||||
attempt = AccessAttempt(
|
||||
|
|
@ -443,7 +654,7 @@ class ClientCacheKeyTestCase(AxesTestCase):
|
|||
path_info=request.META.get("PATH_INFO", "<unknown>"),
|
||||
failures_since_start=0,
|
||||
)
|
||||
self.assertEqual([cache_hash_key], get_client_cache_key(attempt))
|
||||
self.assertEqual([cache_hash_key], get_client_cache_keys(attempt))
|
||||
|
||||
|
||||
class UsernameTestCase(AxesTestCase):
|
||||
|
|
@ -554,6 +765,53 @@ def get_username(request, credentials: dict) -> str:
|
|||
return "username"
|
||||
|
||||
|
||||
def get_ip(request: HttpRequest) -> str:
|
||||
return "127.0.0.1"
|
||||
|
||||
|
||||
class ClientIpAddressTestCase(AxesTestCase):
|
||||
@override_settings(AXES_CLIENT_IP_CALLABLE=get_ip)
|
||||
def test_get_client_ip_address(self):
|
||||
self.assertEqual(get_client_ip_address(HttpRequest()), "127.0.0.1")
|
||||
|
||||
@override_settings(AXES_CLIENT_IP_CALLABLE="tests.test_helpers.get_ip")
|
||||
def test_get_client_ip_address_str(self):
|
||||
self.assertEqual(get_client_ip_address(HttpRequest()), "127.0.0.1")
|
||||
|
||||
@override_settings(
|
||||
AXES_CLIENT_IP_CALLABLE=lambda request: "127.0.0.1"
|
||||
) # pragma: no cover
|
||||
def test_get_client_ip_address_lambda(self):
|
||||
self.assertEqual(get_client_ip_address(HttpRequest()), "127.0.0.1")
|
||||
|
||||
@override_settings(AXES_CLIENT_IP_CALLABLE=True)
|
||||
def test_get_client_ip_address_not_callable(self):
|
||||
with self.assertRaises(TypeError):
|
||||
get_client_ip_address(HttpRequest())
|
||||
|
||||
@override_settings(AXES_CLIENT_IP_CALLABLE=lambda: None) # pragma: no cover
|
||||
def test_get_client_ip_address_invalid_callable_too_few_arguments(self):
|
||||
with self.assertRaises(TypeError):
|
||||
get_client_ip_address(HttpRequest())
|
||||
|
||||
@override_settings(
|
||||
AXES_CLIENT_IP_CALLABLE=lambda request, extra: None
|
||||
) # pragma: no cover
|
||||
def test_get_client_ip_address_invalid_callable_too_many_arguments(self):
|
||||
with self.assertRaises(TypeError):
|
||||
get_client_ip_address(HttpRequest())
|
||||
|
||||
def test_get_client_ip_address_with_ipware(self):
|
||||
request = HttpRequest()
|
||||
request.META["REMOTE_ADDR"] = "127.0.0.2"
|
||||
self.assertEqual(get_client_ip_address(request, use_ipware=True), "127.0.0.2")
|
||||
|
||||
def test_get_client_ip_address_without_ipware(self):
|
||||
request = HttpRequest()
|
||||
request.META["REMOTE_ADDR"] = "127.0.0.3"
|
||||
self.assertEqual(get_client_ip_address(request, use_ipware=False), "127.0.0.3")
|
||||
|
||||
|
||||
class IPWhitelistTestCase(AxesTestCase):
|
||||
def setUp(self):
|
||||
self.request = HttpRequest()
|
||||
|
|
@ -670,6 +928,18 @@ class AxesCoolOffTestCase(AxesTestCase):
|
|||
def test_get_cool_off_int(self):
|
||||
self.assertEqual(get_cool_off(), timedelta(hours=2))
|
||||
|
||||
@override_settings(AXES_COOLOFF_TIME=2.0)
|
||||
def test_get_cool_off_float(self):
|
||||
self.assertEqual(get_cool_off(), timedelta(minutes=120))
|
||||
|
||||
@override_settings(AXES_COOLOFF_TIME=0.25)
|
||||
def test_get_cool_off_float_lt_0(self):
|
||||
self.assertEqual(get_cool_off(), timedelta(minutes=15))
|
||||
|
||||
@override_settings(AXES_COOLOFF_TIME=1.7)
|
||||
def test_get_cool_off_float_gt_0(self):
|
||||
self.assertEqual(get_cool_off(), timedelta(seconds=6120))
|
||||
|
||||
@override_settings(AXES_COOLOFF_TIME=lambda: timedelta(seconds=30))
|
||||
def test_get_cool_off_callable(self):
|
||||
self.assertEqual(get_cool_off(), timedelta(seconds=30))
|
||||
|
|
@ -718,12 +988,12 @@ class AxesLockoutTestCase(AxesTestCase):
|
|||
|
||||
def test_get_lockout_response(self):
|
||||
response = get_lockout_response(self.request, self.credentials)
|
||||
self.assertEqual(403, response.status_code)
|
||||
self.assertEqual(429, response.status_code)
|
||||
|
||||
@override_settings(AXES_HTTP_RESPONSE_CODE=429)
|
||||
@override_settings(AXES_HTTP_RESPONSE_CODE=403)
|
||||
def test_get_lockout_response_with_custom_http_response_code(self):
|
||||
response = get_lockout_response(self.request, self.credentials)
|
||||
self.assertEqual(429, response.status_code)
|
||||
self.assertEqual(403, response.status_code)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_CALLABLE=mock_get_lockout_response)
|
||||
def test_get_lockout_response_override_callable(self):
|
||||
|
|
@ -751,6 +1021,7 @@ class AxesCleanseParamsTestCase(AxesTestCase):
|
|||
"other_sensitive_data": "sensitive",
|
||||
}
|
||||
|
||||
@override_settings(AXES_SENSITIVE_PARAMETERS=[])
|
||||
def test_cleanse_parameters(self):
|
||||
cleansed = cleanse_parameters(self.parameters)
|
||||
self.assertEqual("test_user", cleansed["username"])
|
||||
|
|
@ -772,6 +1043,7 @@ class AxesCleanseParamsTestCase(AxesTestCase):
|
|||
self.assertEqual("********************", cleansed["password"])
|
||||
self.assertEqual("********************", cleansed["other_sensitive_data"])
|
||||
|
||||
@override_settings(AXES_SENSITIVE_PARAMETERS=[])
|
||||
@override_settings(AXES_PASSWORD_FORM_FIELD=None)
|
||||
def test_cleanse_parameters_override_empty(self):
|
||||
cleansed = cleanse_parameters(self.parameters)
|
||||
|
|
|
|||
|
|
@ -3,10 +3,14 @@ from unittest.mock import patch
|
|||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from axes import __version__
|
||||
from axes.apps import AppConfig
|
||||
from axes.models import AccessAttempt, AccessLog
|
||||
from tests.base import AxesTestCase
|
||||
|
||||
_BEGIN = "AXES: BEGIN version %s, %s"
|
||||
_VERSION = __version__
|
||||
|
||||
|
||||
@patch("axes.apps.AppConfig.initialized", False)
|
||||
@patch("axes.apps.log")
|
||||
|
|
@ -30,25 +34,26 @@ class AppsTestCase(AxesTestCase):
|
|||
AppConfig.initialize()
|
||||
self.assertFalse(log.info.called)
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
def test_axes_config_log_user_only(self, log):
|
||||
AppConfig.initialize()
|
||||
log.info.assert_called_with("AXES: blocking by username only.")
|
||||
log.info.assert_called_with(_BEGIN, _VERSION, "blocking by username")
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=False)
|
||||
def test_axes_config_log_ip_only(self, log):
|
||||
AppConfig.initialize()
|
||||
log.info.assert_called_with("AXES: blocking by IP only.")
|
||||
log.info.assert_called_with(_BEGIN, _VERSION, "blocking by ip_address")
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
def test_axes_config_log_user_ip(self, log):
|
||||
AppConfig.initialize()
|
||||
log.info.assert_called_with("AXES: blocking by combination of username and IP.")
|
||||
log.info.assert_called_with(
|
||||
_BEGIN, _VERSION, "blocking by combination of username and ip_address"
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
def test_axes_config_log_user_or_ip(self, log):
|
||||
AppConfig.initialize()
|
||||
log.info.assert_called_with("AXES: blocking by username or IP.")
|
||||
log.info.assert_called_with(_BEGIN, _VERSION, "blocking by username or ip_address")
|
||||
|
||||
|
||||
class AccessLogTestCase(AxesTestCase):
|
||||
|
|
@ -60,11 +65,12 @@ class AccessLogTestCase(AxesTestCase):
|
|||
self.login(is_valid_username=True, is_valid_password=True)
|
||||
self.assertIsNone(AccessLog.objects.latest("id").logout_time)
|
||||
|
||||
response = self.client.get(reverse("admin:logout"))
|
||||
response = self.client.post(reverse("admin:logout"))
|
||||
self.assertContains(response, "Logged out")
|
||||
|
||||
self.assertIsNotNone(AccessLog.objects.latest("id").logout_time)
|
||||
|
||||
@override_settings(DATA_UPLOAD_MAX_NUMBER_FIELDS=1500)
|
||||
def test_log_data_truncated(self):
|
||||
"""
|
||||
Test that get_query_str properly truncates data to the max_length (default 1024).
|
||||
|
|
@ -80,7 +86,7 @@ class AccessLogTestCase(AxesTestCase):
|
|||
AccessLog.objects.all().delete()
|
||||
|
||||
response = self.login(is_valid_username=True, is_valid_password=True)
|
||||
response = self.client.get(reverse("admin:logout"))
|
||||
response = self.client.post(reverse("admin:logout"))
|
||||
|
||||
self.assertEqual(AccessLog.objects.all().count(), 0)
|
||||
self.assertContains(response, "Logged out", html=True)
|
||||
|
|
@ -103,7 +109,7 @@ class AccessLogTestCase(AxesTestCase):
|
|||
AccessLog.objects.all().delete()
|
||||
|
||||
response = self.login(is_valid_username=True, is_valid_password=True)
|
||||
response = self.client.get(reverse("admin:logout"))
|
||||
response = self.client.post(reverse("admin:logout"))
|
||||
|
||||
self.assertEqual(AccessLog.objects.count(), 0)
|
||||
self.assertContains(response, "Logged out", html=True)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ Integration tests for the login handling.
|
|||
|
||||
TODO: Clean up the tests in this module.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from importlib import import_module
|
||||
from time import sleep
|
||||
|
||||
from django.contrib.auth import get_user_model, login, logout
|
||||
from django.http import HttpRequest
|
||||
|
|
@ -12,7 +13,7 @@ from django.test import override_settings, TestCase
|
|||
from django.urls import reverse
|
||||
|
||||
from axes.conf import settings
|
||||
from axes.helpers import get_cache, make_cache_key_list
|
||||
from axes.helpers import get_cache, make_cache_key_list, get_cool_off, get_failure_limit
|
||||
from axes.models import AccessAttempt
|
||||
from tests.base import AxesTestCase
|
||||
|
||||
|
|
@ -27,7 +28,7 @@ class DjangoLoginTestCase(TestCase):
|
|||
self.username = "john.doe"
|
||||
self.password = "hunter2"
|
||||
|
||||
self.user = get_user_model().objects.create(username=self.username)
|
||||
self.user = get_user_model().objects.create(username=self.username, is_staff=True)
|
||||
self.user.set_password(self.password)
|
||||
self.user.save()
|
||||
self.user.backend = "django.contrib.auth.backends.ModelBackend"
|
||||
|
|
@ -46,13 +47,19 @@ class DjangoContribAuthLoginTestCase(DjangoLoginTestCase):
|
|||
class DjangoTestClientLoginTestCase(DjangoLoginTestCase):
|
||||
def test_client_login(self):
|
||||
self.client.login(username=self.username, password=self.password)
|
||||
response = self.client.get(reverse("admin:index"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_client_logout(self):
|
||||
self.client.login(username=self.username, password=self.password)
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse("admin:index"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_client_force_login(self):
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("admin:index"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class DatabaseLoginTestCase(AxesTestCase):
|
||||
|
|
@ -83,9 +90,9 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
LOGIN_FORM_KEY = '<input type="submit" value="Log in" />'
|
||||
ATTEMPT_NOT_BLOCKED = 200
|
||||
ALLOWED = 302
|
||||
BLOCKED = 403
|
||||
BLOCKED = 429
|
||||
|
||||
def _login(self, username, password, ip_addr="127.0.0.1", **kwargs):
|
||||
def _login(self, username, password, ip_addr="127.0.0.1", user_agent="test-browser", **kwargs):
|
||||
"""
|
||||
Login a user and get the response.
|
||||
|
||||
|
|
@ -100,13 +107,13 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
reverse("admin:login"),
|
||||
post_data,
|
||||
REMOTE_ADDR=ip_addr,
|
||||
HTTP_USER_AGENT="test-browser",
|
||||
HTTP_USER_AGENT=user_agent,
|
||||
)
|
||||
|
||||
def _lockout_user_from_ip(self, username, ip_addr):
|
||||
def _lockout_user_from_ip(self, username, ip_addr, user_agent="test-browser"):
|
||||
for _ in range(settings.AXES_FAILURE_LIMIT):
|
||||
response = self._login(
|
||||
username=username, password=self.WRONG_PASSWORD, ip_addr=ip_addr
|
||||
username=username, password=self.WRONG_PASSWORD, ip_addr=ip_addr, user_agent=user_agent,
|
||||
)
|
||||
return response
|
||||
|
||||
|
|
@ -181,10 +188,11 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
|
||||
self.assertTrue(self.attempt_count())
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
def test_lockout_by_combination_user_and_ip(self):
|
||||
"""
|
||||
Test login failure when AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP is True.
|
||||
Test login failure when lockout parameters is combination
|
||||
of username and ip_address.
|
||||
"""
|
||||
|
||||
# test until one try before the limit
|
||||
|
|
@ -196,12 +204,12 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
# So, we shouldn't have gotten a lock-out yet.
|
||||
# But we should get one now
|
||||
response = self.login(is_valid_username=True, is_valid_password=False)
|
||||
self.assertContains(response, self.LOCKED_MESSAGE, status_code=403)
|
||||
self.assertContains(response, self.LOCKED_MESSAGE, status_code=429)
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
def test_lockout_by_only_user_failures(self):
|
||||
"""
|
||||
Test login failure when AXES_ONLY_USER_FAILURES is True.
|
||||
Test login failure when lockout parameter is username.
|
||||
"""
|
||||
|
||||
# test until one try before the limit
|
||||
|
|
@ -237,6 +245,139 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response, self.LOGIN_FORM_KEY, status_code=self.ALLOWED, html=True
|
||||
)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["user_agent"])
|
||||
def test_lockout_by_user_agent_only(self):
|
||||
"""
|
||||
Test login failure when lockout parameter is only user_agent
|
||||
"""
|
||||
# User is locked out with "test-browser" user agent.
|
||||
self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
|
||||
|
||||
# Test he is locked:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is locked with another username:
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is locked with another ip:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test with another user agent:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser-2")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["ip_address", "username", "user_agent"])
|
||||
def test_lockout_by_all_parameters(self):
|
||||
# User is locked out with "test-browser" user agent.
|
||||
self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
|
||||
|
||||
# Test he is locked:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is locked by username:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser2")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is locked by ip:
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser2")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is locked by user_agent:
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is allowed to login with different username, ip and user_agent
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser2")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "username", "user_agent"]])
|
||||
def test_lockout_by_combination_of_all_parameters(self):
|
||||
# User is locked out with "test-browser" user agent.
|
||||
self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
|
||||
|
||||
# Test he is locked:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is allowed to login with different username:
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
# Test he is allowed to login with different IP:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
# Test he is allowed to login with different user_agent:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser2")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
# Test he is allowed to login with different username, ip and user_agent
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser2")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["ip_address", ["username", "user_agent"]])
|
||||
def test_lockout_by_ip_or_username_and_user_agent(self):
|
||||
# User is locked out with "test-browser" user agent.
|
||||
self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
|
||||
|
||||
# Test he is locked:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is locked by ip:
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser2")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is locked by username and user_agent:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is allowed to login with different username and ip
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
# Test he is allowed to login with different user_agent and ip
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser2")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
# Test he is allowed to login with different username, ip and user_agent
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser2")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "user_agent"], ["username", "user_agent"]])
|
||||
def test_lockout_by_ip_and_user_agent_or_username_and_user_agent(self):
|
||||
# User is locked out with "test-browser" user agent.
|
||||
self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
|
||||
|
||||
# Test he is locked:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is locked by ip and user_agent:
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is locked by username and user_agent:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test he is allowed to login with different username and ip
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
# Test he is allowed to login with different user_agent
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser2")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
# Test he is allowed to login with different username, ip and user_agent
|
||||
response = self._login("username2", self.VALID_PASSWORD, ip_addr=self.IP_2, user_agent="test-browser2")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
|
||||
# Test for true and false positives when blocking by IP *OR* user (default)
|
||||
# Cache disabled. Default settings.
|
||||
def test_lockout_by_ip_blocks_when_same_user_same_ip_without_cache(self):
|
||||
|
|
@ -273,7 +414,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
|
||||
# Test for true and false positives when blocking by user only.
|
||||
# Cache disabled. When AXES_ONLY_USER_FAILURES = True
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
def test_lockout_by_user_blocks_when_same_user_same_ip_without_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -282,7 +423,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
def test_lockout_by_user_blocks_when_same_user_diff_ip_without_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -291,7 +432,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
def test_lockout_by_user_allows_when_diff_user_same_ip_without_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -300,7 +441,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
def test_lockout_by_user_allows_when_diff_user_diff_ip_without_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -309,7 +450,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
def test_lockout_by_user_with_empty_username_allows_other_users_without_cache(self):
|
||||
# User with empty username is locked out from IP 1.
|
||||
self._lockout_user_from_ip(username="", ip_addr=self.IP_1)
|
||||
|
|
@ -320,7 +461,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
|
||||
# Test for true and false positives when blocking by user and IP together.
|
||||
# Cache disabled. When LOCK_OUT_BY_COMBINATION_USER_AND_IP = True
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
def test_lockout_by_user_and_ip_blocks_when_same_user_same_ip_without_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -329,7 +470,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
def test_lockout_by_user_and_ip_allows_when_same_user_diff_ip_without_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -338,7 +479,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_without_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -347,7 +488,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
def test_lockout_by_user_and_ip_allows_when_diff_user_diff_ip_without_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -356,7 +497,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
def test_lockout_by_user_and_ip_with_empty_username_allows_other_users_without_cache(
|
||||
self,
|
||||
):
|
||||
|
|
@ -367,6 +508,19 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self.client.get(reverse("admin:login"), REMOTE_ADDR=self.IP_1)
|
||||
self.assertContains(response, self.LOGIN_FORM_KEY, status_code=200, html=True)
|
||||
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["ip_address", "user_agent"]])
|
||||
def test_lockout_by_user_still_allows_login_with_differnet_user_agent(self):
|
||||
# User with empty username is locked out with "test-browser" user agent.
|
||||
self._lockout_user_from_ip(username="username", ip_addr=self.IP_1, user_agent="test-browser")
|
||||
|
||||
# Test he is locked:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser")
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
# Test with another user agent:
|
||||
response = self._login("username", self.VALID_PASSWORD, ip_addr=self.IP_1, user_agent="test-browser-2")
|
||||
self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
|
||||
|
||||
# Test for true and false positives when blocking by IP *OR* user (default)
|
||||
# With cache enabled. Default criteria.
|
||||
def test_lockout_by_ip_blocks_when_same_user_same_ip_using_cache(self):
|
||||
|
|
@ -401,7 +555,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
def test_lockout_by_user_with_empty_username_allows_other_users_using_cache(self):
|
||||
# User with empty username is locked out from IP 1.
|
||||
self._lockout_user_from_ip(username="", ip_addr=self.IP_1)
|
||||
|
|
@ -412,7 +566,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
|
||||
# Test for true and false positives when blocking by user only.
|
||||
# With cache enabled. When AXES_ONLY_USER_FAILURES = True
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
def test_lockout_by_user_blocks_when_same_user_same_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -421,7 +575,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
def test_lockout_by_user_blocks_when_same_user_diff_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -430,7 +584,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
def test_lockout_by_user_allows_when_diff_user_same_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -439,7 +593,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_ONLY_USER_FAILURES=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
|
||||
def test_lockout_by_user_allows_when_diff_user_diff_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -450,7 +604,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
|
||||
# Test for true and false positives when blocking by user and IP together.
|
||||
# With cache enabled. When LOCK_OUT_BY_COMBINATION_USER_AND_IP = True
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
def test_lockout_by_user_and_ip_blocks_when_same_user_same_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -459,7 +613,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
def test_lockout_by_user_and_ip_allows_when_same_user_diff_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -468,7 +622,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -477,7 +631,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
def test_lockout_by_user_and_ip_allows_when_diff_user_diff_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -487,7 +641,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(
|
||||
AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True, AXES_FAILURE_LIMIT=2
|
||||
AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]], AXES_FAILURE_LIMIT=2
|
||||
)
|
||||
def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_using_cache_multiple_attempts(
|
||||
self,
|
||||
|
|
@ -516,7 +670,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
|
||||
def test_lockout_by_user_and_ip_with_empty_username_allows_other_users_using_cache(
|
||||
self,
|
||||
):
|
||||
|
|
@ -529,7 +683,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
|
||||
# Test for true and false positives when blocking by user or IP together.
|
||||
# With cache enabled. When AXES_LOCK_OUT_BY_USER_OR_IP = True
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
def test_lockout_by_user_or_ip_blocks_when_same_user_same_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -538,7 +692,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
def test_lockout_by_user_or_ip_allows_when_same_user_diff_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -547,7 +701,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
def test_lockout_by_user_or_ip_allows_when_diff_user_same_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -556,7 +710,9 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertEqual(response.status_code, self.BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True, AXES_FAILURE_LIMIT=3)
|
||||
@override_settings(
|
||||
AXES_LOCKOUT_PARAMETERS=["username", "ip_address"], AXES_FAILURE_LIMIT=3
|
||||
)
|
||||
def test_lockout_by_user_or_ip_allows_when_diff_user_same_ip_using_cache_multiple_attempts(
|
||||
self,
|
||||
):
|
||||
|
|
@ -586,7 +742,9 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_3, self.WRONG_PASSWORD, ip_addr=self.IP_1)
|
||||
self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True, AXES_FAILURE_LIMIT=3)
|
||||
@override_settings(
|
||||
AXES_LOCKOUT_PARAMETERS=["username", "ip_address"], AXES_FAILURE_LIMIT=3
|
||||
)
|
||||
def test_lockout_by_user_or_ip_allows_when_diff_user_same_ip_using_cache_multiple_failed_attempts(
|
||||
self,
|
||||
):
|
||||
|
|
@ -611,7 +769,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
def test_lockout_by_user_or_ip_allows_when_diff_user_diff_ip_using_cache(self):
|
||||
# User 1 is locked out from IP 1.
|
||||
self._lockout_user1_from_ip1()
|
||||
|
|
@ -620,7 +778,7 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
|
||||
self.assertEqual(response.status_code, self.ALLOWED)
|
||||
|
||||
@override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
|
||||
@override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
|
||||
def test_lockout_by_user_or_ip_with_empty_username_allows_other_users_using_cache(
|
||||
self,
|
||||
):
|
||||
|
|
@ -631,6 +789,46 @@ class DatabaseLoginTestCase(AxesTestCase):
|
|||
response = self.client.get(reverse("admin:login"), REMOTE_ADDR=self.IP_1)
|
||||
self.assertContains(response, self.LOGIN_FORM_KEY, status_code=200, html=True)
|
||||
|
||||
@override_settings(
|
||||
AXES_COOLOFF_TIME=timedelta(seconds=1),
|
||||
AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT=False,
|
||||
AXES_FAILURE_LIMIT=2,
|
||||
)
|
||||
def test_login_during_lockout_doesnt_reset_cool_off_time(self):
|
||||
# Lockout
|
||||
for _ in range(get_failure_limit(None, None)):
|
||||
self.login(self.USER_1)
|
||||
|
||||
# Attempt during lockout
|
||||
sleep_time = get_cool_off().total_seconds() / 2
|
||||
sleep(sleep_time)
|
||||
self.login(self.USER_1)
|
||||
sleep(sleep_time)
|
||||
|
||||
# New attempt after initial lockout period: should work
|
||||
response = self.login(is_valid_username=True, is_valid_password=True)
|
||||
self.assertNotContains(response, self.LOCKED_MESSAGE, status_code=self.ALLOWED)
|
||||
|
||||
@override_settings(
|
||||
AXES_COOLOFF_TIME=timedelta(seconds=1),
|
||||
AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT=True,
|
||||
AXES_FAILURE_LIMIT=2,
|
||||
)
|
||||
def test_login_during_lockout_does_reset_cool_off_time(self):
|
||||
# Lockout
|
||||
for _ in range(get_failure_limit(None, None)):
|
||||
self.login(self.USER_1)
|
||||
|
||||
# Attempt during lockout
|
||||
sleep_time = get_cool_off().total_seconds() / 2
|
||||
sleep(sleep_time)
|
||||
self.login(self.USER_1)
|
||||
sleep(sleep_time)
|
||||
|
||||
# New attempt after initial lockout period: should not work
|
||||
response = self.login(is_valid_username=True, is_valid_password=True)
|
||||
self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
|
||||
|
||||
|
||||
# Test the same logic with cache handler
|
||||
@override_settings(AXES_HANDLER="axes.handlers.cache.AxesCacheHandler")
|
||||
|
|
|
|||
|
|
@ -56,18 +56,22 @@ class ManagementCommandTestCase(AxesTestCase):
|
|||
username="john.doe", ip_address="10.0.0.2", failures_since_start="15"
|
||||
)
|
||||
|
||||
AccessAttempt.objects.create(
|
||||
username="richard.doe", ip_address="10.0.0.4", failures_since_start="12"
|
||||
)
|
||||
|
||||
def test_axes_list_attempts(self):
|
||||
out = StringIO()
|
||||
call_command("axes_list_attempts", stdout=out)
|
||||
|
||||
expected = "10.0.0.1\tjane.doe\t4\n10.0.0.2\tjohn.doe\t15\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())
|
||||
|
||||
def test_axes_reset(self):
|
||||
out = StringIO()
|
||||
call_command("axes_reset", stdout=out)
|
||||
|
||||
expected = "2 attempts removed.\n"
|
||||
expected = "3 attempts removed.\n"
|
||||
self.assertEqual(expected, out.getvalue())
|
||||
|
||||
def test_axes_reset_not_found(self):
|
||||
|
|
@ -87,6 +91,13 @@ class ManagementCommandTestCase(AxesTestCase):
|
|||
expected = "1 attempts removed.\n"
|
||||
self.assertEqual(expected, out.getvalue())
|
||||
|
||||
def test_axes_reset_ip_username(self):
|
||||
out = StringIO()
|
||||
call_command("axes_reset_ip_username", "10.0.0.4", "richard.doe", stdout=out)
|
||||
|
||||
expected = "1 attempts removed.\n"
|
||||
self.assertEqual(expected, out.getvalue())
|
||||
|
||||
def test_axes_reset_ip_not_found(self):
|
||||
out = StringIO()
|
||||
call_command("axes_reset_ip", "10.0.0.3", stdout=out)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ def get_username(request, credentials: dict) -> str:
|
|||
|
||||
class MiddlewareTestCase(AxesTestCase):
|
||||
STATUS_SUCCESS = 200
|
||||
STATUS_LOCKOUT = 403
|
||||
STATUS_LOCKOUT = 429
|
||||
|
||||
def setUp(self):
|
||||
self.request = HttpRequest()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from django.db.migrations.autodetector import MigrationAutodetector
|
|||
from django.db.migrations.executor import MigrationExecutor
|
||||
from django.db.migrations.state import ProjectState
|
||||
|
||||
from axes.models import AccessAttempt, AccessLog
|
||||
from axes.models import AccessAttempt, AccessLog, AccessFailureLog
|
||||
from tests.base import AxesTestCase
|
||||
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ class ModelsTestCase(AxesTestCase):
|
|||
failures_since_start=self.failures_since_start
|
||||
)
|
||||
self.access_log = AccessLog()
|
||||
self.access_failure_log = AccessFailureLog()
|
||||
|
||||
def test_access_attempt_str(self):
|
||||
self.assertIn("Access", str(self.access_attempt))
|
||||
|
|
@ -23,6 +24,9 @@ class ModelsTestCase(AxesTestCase):
|
|||
def test_access_log_str(self):
|
||||
self.assertIn("Access", str(self.access_log))
|
||||
|
||||
def test_access_failure_log_str(self):
|
||||
self.assertIn("Failed", str(self.access_failure_log))
|
||||
|
||||
|
||||
class MigrationsTestCase(AxesTestCase):
|
||||
def test_missing_migrations(self):
|
||||
|
|
|
|||
Loading…
Reference in a new issue