├── tests
├── __init__.py
├── urls_empty.py
├── urls.py
├── test_signals.py
├── test_failures.py
├── test_backends.py
├── test_admin.py
├── test_models.py
├── test_middleware.py
├── test_decorators.py
├── settings.py
├── test_management.py
├── test_checks.py
├── test_logging.py
├── test_attempts.py
└── base.py
├── axes
├── handlers
│ ├── __init__.py
│ ├── test.py
│ ├── dummy.py
│ ├── proxy.py
│ ├── cache.py
│ └── base.py
├── management
│ ├── __init__.py
│ └── commands
│ │ ├── __init__.py
│ │ ├── axes_list_attempts.py
│ │ ├── axes_reset.py
│ │ ├── axes_reset_ip.py
│ │ ├── axes_reset_username.py
│ │ ├── axes_reset_ip_username.py
│ │ ├── axes_reset_logs.py
│ │ └── axes_reset_failure_logs.py
├── migrations
│ ├── __init__.py
│ ├── 0005_remove_accessattempt_trusted.py
│ ├── 0006_remove_accesslog_trusted.py
│ ├── 0009_add_session_hash.py
│ ├── 0010_accessattemptexpiration.py
│ ├── 0007_alter_accessattempt_unique_together.py
│ ├── 0002_auto_20151217_2044.py
│ ├── 0003_auto_20160322_0929.py
│ ├── 0004_auto_20181024_1538.py
│ ├── 0008_accessfailurelog.py
│ └── 0001_initial.py
├── __init__.py
├── locale
│ ├── ar
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── de
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── fa
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── fr
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── id
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── pl
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── ru
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ └── tr
│ │ └── LC_MESSAGES
│ │ ├── django.mo
│ │ └── django.po
├── exceptions.py
├── decorators.py
├── attempts.py
├── apps.py
├── signals.py
├── utils.py
├── middleware.py
├── models.py
├── backends.py
├── admin.py
├── conf.py
└── checks.py
├── .pre-commit-config.yaml
├── docs
├── 10_changelog.rst
├── 9_contributing.rst
├── images
│ └── flow.png
├── _static
│ └── css
│ │ └── custom_theme.css
├── index.rst
├── 8_reference.rst
├── 1_requirements.rst
├── 7_architecture.rst
├── conf.py
├── 3_usage.rst
├── Makefile
├── 5_customization.rst
└── 6_integration.rst
├── mypy.ini
├── .prospector.yaml
├── codecov.yml
├── requirements.txt
├── manage.py
├── .gitignore
├── .github
├── dependabot.yml
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ ├── release.yml
│ ├── codeql.yml
│ └── test.yml
├── .readthedocs.yaml
├── LICENSE
├── pyproject.toml
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.rst
├── setup.py
└── README.rst
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/axes/handlers/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/axes/management/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/axes/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos: []
2 |
--------------------------------------------------------------------------------
/axes/management/commands/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/urls_empty.py:
--------------------------------------------------------------------------------
1 | urlpatterns: list = []
2 |
--------------------------------------------------------------------------------
/docs/10_changelog.rst:
--------------------------------------------------------------------------------
1 | .. changelog:
2 |
3 | .. include:: ../CHANGES.rst
4 |
--------------------------------------------------------------------------------
/docs/9_contributing.rst:
--------------------------------------------------------------------------------
1 | .. _contributing:
2 |
3 | .. include:: ../CONTRIBUTING.rst
4 |
--------------------------------------------------------------------------------
/docs/images/flow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-axes/master/docs/images/flow.png
--------------------------------------------------------------------------------
/axes/__init__.py:
--------------------------------------------------------------------------------
1 | from importlib.metadata import version
2 |
3 | __version__ = version("django-axes")
4 |
--------------------------------------------------------------------------------
/axes/locale/ar/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-axes/master/axes/locale/ar/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/axes/locale/de/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-axes/master/axes/locale/de/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/axes/locale/fa/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-axes/master/axes/locale/fa/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/axes/locale/fr/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-axes/master/axes/locale/fr/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/axes/locale/id/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-axes/master/axes/locale/id/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/axes/locale/pl/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-axes/master/axes/locale/pl/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/axes/locale/ru/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-axes/master/axes/locale/ru/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/axes/locale/tr/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-axes/master/axes/locale/tr/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/mypy.ini:
--------------------------------------------------------------------------------
1 | [mypy]
2 | python_version = 3.12
3 | ignore_missing_imports = True
4 |
5 | [mypy-axes.migrations.*]
6 | ignore_errors = True
7 |
--------------------------------------------------------------------------------
/tests/urls.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.urls import path
3 |
4 |
5 | urlpatterns = [path("admin/", admin.site.urls)]
6 |
--------------------------------------------------------------------------------
/docs/_static/css/custom_theme.css:
--------------------------------------------------------------------------------
1 | @import url("theme.css");
2 |
3 | .wy-nav-content {
4 | max-width: none;
5 | }
6 |
7 | .wy-table-responsive table td, .wy-table-responsive table th {
8 | white-space: inherit;
9 | }
10 |
--------------------------------------------------------------------------------
/.prospector.yaml:
--------------------------------------------------------------------------------
1 | ignore-paths:
2 | - docs
3 | - axes/migrations
4 |
5 | pycodestyle:
6 | options:
7 | max-line-length: 142
8 |
9 | pylint:
10 | disable:
11 | - django-not-configured
12 |
13 | pyflakes:
14 | disable:
15 | - F401
16 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | coverage:
2 | status:
3 | patch: off
4 | project:
5 | default:
6 | # Minimum test coverage required for pass
7 | target: 90%
8 | # Maximum test coverage change allowed for pass
9 | threshold: 20%
10 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | -e .
2 | black==25.11.0
3 | coverage==7.13.0
4 | django-ipware>=3
5 | mypy==1.19.1
6 | prospector==1.17.3
7 | pytest-cov==7.0.0
8 | pytest-django==4.11.1
9 | pytest-subtests==0.15.0
10 | pytest==9.0.2
11 | sphinx_rtd_theme==3.0.2
12 | tox==4.32.0
13 |
--------------------------------------------------------------------------------
/axes/migrations/0005_remove_accessattempt_trusted.py:
--------------------------------------------------------------------------------
1 | from django.db import migrations
2 |
3 |
4 | class Migration(migrations.Migration):
5 | dependencies = [("axes", "0004_auto_20181024_1538")]
6 |
7 | operations = [migrations.RemoveField(model_name="accessattempt", name="trusted")]
8 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import os
4 | import sys
5 |
6 | if __name__ == "__main__":
7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
8 |
9 | from django.core.management import execute_from_command_line
10 |
11 | execute_from_command_line(sys.argv)
12 |
--------------------------------------------------------------------------------
/axes/migrations/0006_remove_accesslog_trusted.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.0.4 on 2019-03-13 08:55
2 |
3 | from django.db import migrations
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [("axes", "0005_remove_accessattempt_trusted")]
8 |
9 | operations = [migrations.RemoveField(model_name="accesslog", name="trusted")]
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.egg-info
2 | *.pyc
3 | *.swp
4 | .coverage
5 | coverage.xml
6 | .DS_Store
7 | .idea
8 | .mypy_cache/
9 | .project
10 | .pydevproject
11 | .python-version
12 | .tox
13 | build/
14 | dist/
15 | docs/_build
16 | test.db
17 | .eggs
18 | pip-wheel-metadata
19 | .vscode/
20 | .env
21 | .venv
22 | env/
23 | venv/
24 | ENV/
25 | env.bak/
26 | venv.bak/
27 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "pip"
4 | directory: "/"
5 | schedule:
6 | interval: "daily"
7 | time: "12:00"
8 | open-pull-requests-limit: 10
9 | - package-ecosystem: "github-actions"
10 | directory: "/"
11 | schedule:
12 | interval: "daily"
13 | time: "12:00"
14 | open-pull-requests-limit: 10
15 |
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | # Read the Docs configuration file for Sphinx projects
2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
3 | version: 2
4 | build:
5 | os: ubuntu-22.04
6 | tools:
7 | python: "3.11"
8 | sphinx:
9 | configuration: docs/conf.py
10 | formats:
11 | - pdf
12 | - epub
13 | python:
14 | install:
15 | - requirements: requirements.txt
16 |
--------------------------------------------------------------------------------
/axes/exceptions.py:
--------------------------------------------------------------------------------
1 | from django.core.exceptions import PermissionDenied
2 |
3 |
4 | class AxesBackendPermissionDenied(PermissionDenied):
5 | """
6 | Raised by authentication backend on locked out requests to stop the Django authentication flow.
7 | """
8 |
9 |
10 | class AxesBackendRequestParameterRequired(ValueError):
11 | """
12 | Raised by authentication backend on invalid or missing request parameter value.
13 | """
14 |
--------------------------------------------------------------------------------
/axes/management/commands/axes_list_attempts.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 |
3 | from axes.models import AccessAttempt
4 |
5 |
6 | class Command(BaseCommand):
7 | help = "List access attempts"
8 |
9 | def handle(self, *args, **options):
10 | for obj in AccessAttempt.objects.all():
11 | self.stdout.write(
12 | f"{obj.ip_address}\t{obj.username}\t{obj.failures_since_start}"
13 | )
14 |
--------------------------------------------------------------------------------
/axes/management/commands/axes_reset.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 |
3 | from axes.utils import reset
4 |
5 |
6 | class Command(BaseCommand):
7 | help = "Reset all access attempts and lockouts"
8 |
9 | def handle(self, *args, **options):
10 | count = reset()
11 |
12 | if count:
13 | self.stdout.write(f"{count} attempts removed.")
14 | else:
15 | self.stdout.write("No attempts found.")
16 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | .. _index:
2 |
3 | django-axes documentation
4 | =========================
5 |
6 | Contents
7 | --------
8 |
9 | .. toctree::
10 | :maxdepth: 2
11 | :numbered: 1
12 |
13 | 1_requirements
14 | 2_installation
15 | 3_usage
16 | 4_configuration
17 | 5_customization
18 | 6_integration
19 | 7_architecture
20 | 8_reference
21 | 9_contributing
22 | 10_changelog
23 |
24 |
25 | Indices and tables
26 | ------------------
27 |
28 | * :ref:`search`
29 |
--------------------------------------------------------------------------------
/docs/8_reference.rst:
--------------------------------------------------------------------------------
1 | .. _reference:
2 |
3 | API reference
4 | =============
5 |
6 | Axes offers extensible APIs that you can customize to your liking.
7 | You can specialize the following base classes or alternatively use
8 | third party modules as long as they implement the following APIs.
9 |
10 | .. automodule:: axes.handlers.base
11 | :members:
12 |
13 | .. automodule:: axes.backends
14 | :members:
15 | :show-inheritance:
16 |
17 | .. automodule:: axes.middleware
18 | :members:
19 |
--------------------------------------------------------------------------------
/tests/test_signals.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import MagicMock
2 |
3 | from axes.signals import user_locked_out
4 | from tests.base import AxesTestCase
5 |
6 |
7 | class SignalTestCase(AxesTestCase):
8 | def test_send_lockout_signal(self):
9 | """
10 | Test if the lockout signal is correctly emitted when user is locked out.
11 | """
12 |
13 | handler = MagicMock()
14 | user_locked_out.connect(handler)
15 |
16 | self.assertEqual(0, handler.call_count)
17 | self.lockout()
18 | self.assertEqual(1, handler.call_count)
19 |
--------------------------------------------------------------------------------
/axes/migrations/0009_add_session_hash.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.2.2 on 2024-04-30 07:57
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("axes", "0008_accessfailurelog"),
9 | ]
10 |
11 | operations = [
12 | migrations.AddField(
13 | model_name="accesslog",
14 | name="session_hash",
15 | field=models.CharField(
16 | blank=True,
17 | default="",
18 | max_length=64,
19 | verbose_name="Session key hash (sha256)",
20 | ),
21 | ),
22 | ]
23 |
--------------------------------------------------------------------------------
/axes/management/commands/axes_reset_ip.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 |
3 | from axes.utils import reset
4 |
5 |
6 | class Command(BaseCommand):
7 | help = "Reset all access attempts and lockouts for given IP addresses"
8 |
9 | def add_arguments(self, parser):
10 | parser.add_argument("ip", nargs="+", type=str)
11 |
12 | def handle(self, *args, **options):
13 | count = 0
14 |
15 | for ip in options["ip"]:
16 | count += reset(ip=ip)
17 |
18 | if count:
19 | self.stdout.write(f"{count} attempts removed.")
20 | else:
21 | self.stdout.write("No attempts found.")
22 |
--------------------------------------------------------------------------------
/axes/management/commands/axes_reset_username.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 |
3 | from axes.utils import reset
4 |
5 |
6 | class Command(BaseCommand):
7 | help = "Reset all access attempts and lockouts for given usernames"
8 |
9 | def add_arguments(self, parser):
10 | parser.add_argument("username", nargs="+", type=str)
11 |
12 | def handle(self, *args, **options):
13 | count = 0
14 |
15 | for username in options["username"]:
16 | count += reset(username=username)
17 |
18 | if count:
19 | self.stdout.write(f"{count} attempts removed.")
20 | else:
21 | self.stdout.write("No attempts found.")
22 |
--------------------------------------------------------------------------------
/axes/management/commands/axes_reset_ip_username.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 |
3 | from axes.utils import reset
4 |
5 |
6 | class Command(BaseCommand):
7 | help = "Reset all access attempts and lockouts for a given IP address and username"
8 |
9 | def add_arguments(self, parser):
10 | parser.add_argument("ip", type=str)
11 | parser.add_argument("username", type=str)
12 |
13 | def handle(self, *args, **options):
14 | count = reset(ip=options["ip"], username=options["username"])
15 |
16 | if count:
17 | self.stdout.write(f"{count} attempts removed.")
18 | else:
19 | self.stdout.write("No attempts found.")
20 |
--------------------------------------------------------------------------------
/docs/1_requirements.rst:
--------------------------------------------------------------------------------
1 | .. _requirements:
2 |
3 | Requirements
4 | ============
5 |
6 | Axes requires a supported Django version and runs on Python versions 3.9 and above.
7 |
8 | Refer to the project source code repository in
9 | `GitHub `_ and see the
10 | `pyproject.toml file `_ and
11 | `Python package definition `_
12 | to check if your Django and Python version are supported.
13 |
14 | The `GitHub Actions builds `_
15 | test Axes compatibility with the Django master branch for future compatibility as well.
16 |
--------------------------------------------------------------------------------
/axes/decorators.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 |
3 | from axes.handlers.proxy import AxesProxyHandler
4 | from axes.helpers import get_lockout_response
5 |
6 |
7 | def axes_dispatch(func):
8 | @wraps(func)
9 | def inner(request, *args, **kwargs):
10 | if AxesProxyHandler.is_allowed(request):
11 | return func(request, *args, **kwargs)
12 |
13 | return get_lockout_response(request)
14 |
15 | return inner
16 |
17 |
18 | def axes_form_invalid(func):
19 | @wraps(func)
20 | def inner(self, *args, **kwargs):
21 | if AxesProxyHandler.is_allowed(self.request):
22 | return func(self, *args, **kwargs)
23 |
24 | return get_lockout_response(self.request)
25 |
26 | return inner
27 |
--------------------------------------------------------------------------------
/axes/management/commands/axes_reset_logs.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 |
3 | from axes.handlers.proxy import AxesProxyHandler
4 |
5 |
6 | class Command(BaseCommand):
7 | help = "Reset access log records older than given days."
8 |
9 | def add_arguments(self, parser):
10 | parser.add_argument(
11 | "--age",
12 | type=int,
13 | default=30,
14 | help="Maximum age for records to keep in days",
15 | )
16 |
17 | def handle(self, *args, **options):
18 | count = AxesProxyHandler.reset_logs(age_days=options["age"])
19 | if count:
20 | self.stdout.write(f"{count} logs removed.")
21 | else:
22 | self.stdout.write("No logs found.")
23 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for django-axes
4 | title: 'FEATURE REQUEST: Short description of requested feature'
5 | labels: 'feature request'
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/axes/management/commands/axes_reset_failure_logs.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 |
3 | from axes.handlers.proxy import AxesProxyHandler
4 |
5 |
6 | class Command(BaseCommand):
7 | help = "Reset access failure log records older than given days."
8 |
9 | def add_arguments(self, parser):
10 | parser.add_argument(
11 | "--age",
12 | type=int,
13 | default=30,
14 | help="Maximum age for records to keep in days",
15 | )
16 |
17 | def handle(self, *args, **options):
18 | count = AxesProxyHandler.reset_failure_logs(age_days=options["age"])
19 | if count:
20 | self.stdout.write(f"{count} logs removed.")
21 | else:
22 | self.stdout.write("No logs found.")
23 |
--------------------------------------------------------------------------------
/axes/handlers/test.py:
--------------------------------------------------------------------------------
1 | from axes.handlers.base import AxesHandler
2 | from typing import Optional
3 |
4 |
5 | class AxesTestHandler(AxesHandler):
6 | """
7 | Signal handler implementation that does nothing, ideal for a test suite.
8 | """
9 |
10 | def reset_attempts(
11 | self,
12 | *,
13 | ip_address: Optional[str] = None,
14 | username: Optional[str] = None,
15 | ip_or_username: bool = False,
16 | ) -> int:
17 | return 0
18 |
19 | def reset_logs(self, *, age_days: Optional[int] = None) -> int:
20 | return 0
21 |
22 | def is_allowed(self, request, credentials: Optional[dict] = None) -> bool:
23 | return True
24 |
25 | def get_failures(self, request, credentials: Optional[dict] = None) -> int:
26 | return 0
27 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve django-axes
4 | title: 'BUG: Short description of the problem'
5 | labels: 'bug'
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1.
16 | 2.
17 | 3.
18 | 4.
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Your environment**
24 | python version:
25 | django version:
26 | django-axes version:
27 | Operating system:
28 |
29 | **Additional context**
30 | Add any other context about the problem here.
31 |
32 | **Possible implementation**
33 | Not obligatory, but suggest an idea for implementing addition or change
34 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | # What does this PR do?
2 |
3 |
10 |
11 |
12 |
13 | Fixes # (issue)
14 |
15 |
16 | ## Before submitting
17 | - [ ] This PR fixes a typo or improves the docs (you can dismiss the other checks if that's the case).
18 | - [ ] Did you make sure to update the documentation with your changes?
19 | - [ ] Did you write any new necessary tests?
20 |
--------------------------------------------------------------------------------
/axes/attempts.py:
--------------------------------------------------------------------------------
1 | from logging import getLogger
2 | from typing import Optional
3 |
4 | from django.http import HttpRequest
5 | from django.utils.timezone import datetime, now
6 |
7 | from axes.helpers import get_cool_off
8 |
9 | log = getLogger(__name__)
10 |
11 |
12 | def get_cool_off_threshold(request: Optional[HttpRequest] = None) -> datetime:
13 | """
14 | Get threshold for fetching access attempts from the database.
15 | """
16 |
17 | cool_off = get_cool_off(request)
18 | if cool_off is None:
19 | raise TypeError(
20 | "Cool off threshold can not be calculated with settings.AXES_COOLOFF_TIME set to None"
21 | )
22 |
23 | attempt_time = request.axes_attempt_time
24 | if attempt_time is None:
25 | return now() - cool_off
26 | return attempt_time - cool_off
27 |
--------------------------------------------------------------------------------
/axes/handlers/dummy.py:
--------------------------------------------------------------------------------
1 | from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler
2 | from typing import Optional
3 |
4 |
5 | class AxesDummyHandler(AbstractAxesHandler, AxesBaseHandler):
6 | """
7 | Signal handler implementation that does nothing and can be used to disable signal processing.
8 | """
9 |
10 | def is_allowed(self, request, credentials: Optional[dict] = None) -> bool:
11 | return True
12 |
13 | def user_login_failed(self, sender, credentials: dict, request=None, **kwargs):
14 | pass
15 |
16 | def user_logged_in(self, sender, request, user, **kwargs):
17 | pass
18 |
19 | def user_logged_out(self, sender, request, user, **kwargs):
20 | pass
21 |
22 | def get_failures(self, request, credentials: Optional[dict] = None) -> int:
23 | return 0
24 |
--------------------------------------------------------------------------------
/tests/test_failures.py:
--------------------------------------------------------------------------------
1 | from axes.models import AccessFailureLog
2 | from tests.base import AxesTestCase
3 | from axes.helpers import get_failure_limit
4 | from django.test import override_settings
5 |
6 | @override_settings(AXES_ENABLE_ACCESS_FAILURE_LOG=True)
7 | class FailureLogTestCase(AxesTestCase):
8 | def test_failure_log(self):
9 | self.login(is_valid_username=True, is_valid_password=False)
10 | self.assertEqual(AccessFailureLog.objects.count(), 1)
11 | self.assertTrue(AccessFailureLog.objects.filter(username=self.VALID_USERNAME).exists())
12 | self.assertTrue(AccessFailureLog.objects.filter(ip_address=self.ip_address).exists())
13 |
14 | def test_failure_locked_out(self):
15 | self.check_lockout()
16 | self.assertEqual(AccessFailureLog.objects.filter(locked_out=True).count(), 1)
17 |
--------------------------------------------------------------------------------
/tests/test_backends.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import patch, MagicMock
2 |
3 | from axes.backends import AxesBackend
4 | from axes.exceptions import (
5 | AxesBackendRequestParameterRequired,
6 | AxesBackendPermissionDenied,
7 | )
8 | from tests.base import AxesTestCase
9 |
10 |
11 | class BackendTestCase(AxesTestCase):
12 | def test_authenticate_raises_on_missing_request(self):
13 | request = None
14 |
15 | with self.assertRaises(AxesBackendRequestParameterRequired):
16 | AxesBackend().authenticate(request)
17 |
18 | @patch("axes.handlers.proxy.AxesProxyHandler.is_allowed", return_value=False)
19 | def test_authenticate_raises_on_locked_request(self, _):
20 | request = MagicMock()
21 |
22 | with self.assertRaises(AxesBackendPermissionDenied):
23 | AxesBackend().authenticate(request)
24 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 |
8 | permissions:
9 | contents: read
10 |
11 | jobs:
12 | build:
13 | if: github.repository == 'jazzband/django-axes'
14 | runs-on: ubuntu-latest
15 |
16 | steps:
17 | - uses: actions/checkout@v6
18 | with:
19 | fetch-depth: 0
20 |
21 | - name: Set up Python
22 | uses: actions/setup-python@v6
23 | with:
24 | python-version: 3.12
25 |
26 | - name: Install dependencies
27 | run: |
28 | python -m pip install -U pip
29 | python -m pip install -U setuptools twine wheel
30 |
31 | - name: Build package
32 | run: |
33 | python setup.py --version
34 | python setup.py sdist --format=gztar bdist_wheel
35 | twine check dist/*
36 |
37 | - name: Upload packages to Jazzband
38 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
39 | uses: pypa/gh-action-pypi-publish@release/v1
40 | with:
41 | user: jazzband
42 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
43 | repository-url: https://jazzband.co/projects/django-axes/upload
44 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License
2 |
3 | Copyright (c) 2008 Josh VanderLinden
4 | Copyright (c) 2009 Philip Neustrom
5 | Copyright (c) 2016 Jazzband
6 |
7 | Permission is hereby granted, free of charge, to any person obtaining a copy
8 | of this software and associated documentation files (the "Software"), to deal
9 | in the Software without restriction, including without limitation the rights
10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11 | copies of the Software, and to permit persons to whom the Software is
12 | furnished to do so, subject to the following conditions:
13 |
14 | The above copyright notice and this permission notice shall be included in
15 | all copies or substantial portions of the Software.
16 |
17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23 | THE SOFTWARE.
24 |
--------------------------------------------------------------------------------
/tests/test_admin.py:
--------------------------------------------------------------------------------
1 | from contextlib import suppress
2 | from importlib import reload
3 |
4 | from django.contrib import admin
5 | from django.test import override_settings
6 |
7 | import axes.admin
8 | from axes.models import AccessAttempt, AccessLog, AccessFailureLog
9 | from tests.base import AxesTestCase
10 |
11 |
12 | class AxesEnableAdminFlag(AxesTestCase):
13 | def setUp(self):
14 | with suppress(admin.sites.NotRegistered):
15 | admin.site.unregister(AccessAttempt)
16 | with suppress(admin.sites.NotRegistered):
17 | admin.site.unregister(AccessLog)
18 | with suppress(admin.sites.NotRegistered):
19 | admin.site.unregister(AccessFailureLog)
20 |
21 | @override_settings(AXES_ENABLE_ADMIN=False)
22 | def test_disable_admin(self):
23 | reload(axes.admin)
24 | self.assertFalse(admin.site.is_registered(AccessAttempt))
25 | self.assertFalse(admin.site.is_registered(AccessLog))
26 | self.assertFalse(admin.site.is_registered(AccessFailureLog))
27 |
28 | def test_enable_admin_by_default(self):
29 | reload(axes.admin)
30 | self.assertTrue(admin.site.is_registered(AccessAttempt))
31 | self.assertTrue(admin.site.is_registered(AccessLog))
32 | self.assertTrue(admin.site.is_registered(AccessFailureLog))
33 |
--------------------------------------------------------------------------------
/axes/migrations/0010_accessattemptexpiration.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.2.1 on 2025-06-10 20:21
2 |
3 | import django.db.models.deletion
4 | from django.db import migrations, models
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = [
10 | ("axes", "0009_add_session_hash"),
11 | ]
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name="AccessAttemptExpiration",
16 | fields=[
17 | (
18 | "access_attempt",
19 | models.OneToOneField(
20 | on_delete=django.db.models.deletion.CASCADE,
21 | primary_key=True,
22 | related_name="expiration",
23 | serialize=False,
24 | to="axes.accessattempt",
25 | verbose_name="Access Attempt",
26 | ),
27 | ),
28 | (
29 | "expires_at",
30 | models.DateTimeField(
31 | help_text="The time when access attempt expires and is no longer valid.",
32 | verbose_name="Expires At",
33 | ),
34 | ),
35 | ],
36 | options={
37 | "verbose_name": "access attempt expiration",
38 | "verbose_name_plural": "access attempt expirations",
39 | },
40 | ),
41 | ]
42 |
--------------------------------------------------------------------------------
/tests/test_models.py:
--------------------------------------------------------------------------------
1 | from django.apps.registry import apps
2 | from django.db import connection
3 | from django.db.migrations.autodetector import MigrationAutodetector
4 | from django.db.migrations.executor import MigrationExecutor
5 | from django.db.migrations.state import ProjectState
6 |
7 | from axes.models import AccessAttempt, AccessLog, AccessFailureLog
8 | from tests.base import AxesTestCase
9 |
10 |
11 | class ModelsTestCase(AxesTestCase):
12 | def setUp(self):
13 | self.failures_since_start = 42
14 |
15 | self.access_attempt = AccessAttempt(
16 | failures_since_start=self.failures_since_start
17 | )
18 | self.access_log = AccessLog()
19 | self.access_failure_log = AccessFailureLog()
20 |
21 | def test_access_attempt_str(self):
22 | self.assertIn("Access", str(self.access_attempt))
23 |
24 | def test_access_log_str(self):
25 | self.assertIn("Access", str(self.access_log))
26 |
27 | def test_access_failure_log_str(self):
28 | self.assertIn("Failed", str(self.access_failure_log))
29 |
30 |
31 | class MigrationsTestCase(AxesTestCase):
32 | def test_missing_migrations(self):
33 | executor = MigrationExecutor(connection)
34 | autodetector = MigrationAutodetector(
35 | executor.loader.project_state(), ProjectState.from_apps(apps)
36 | )
37 |
38 | changes = autodetector.changes(graph=executor.loader.graph)
39 |
40 | self.assertEqual({}, changes)
41 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools>=30.3.0", "wheel", "setuptools_scm"]
3 |
4 | [tool.pytest.ini_options]
5 | testpaths = "tests"
6 | addopts = "--cov axes --cov-append --cov-branch --cov-report term-missing --cov-report=xml"
7 | DJANGO_SETTINGS_MODULE = "tests.settings"
8 |
9 | [tool.tox]
10 | legacy_tox_ini = """
11 | [tox]
12 | envlist =
13 | py{310,311,312}-dj42
14 | py{310,311,312,313}-dj52
15 | py{312,313,314}-dj60
16 | py312-djmain
17 | py312-djqa
18 |
19 | [gh-actions]
20 | python =
21 | 3.10: py310
22 | 3.11: py311
23 | 3.12: py312
24 | 3.13: py313
25 | 3.14: py314
26 |
27 | [gh-actions:env]
28 | DJANGO =
29 | 4.2: dj42
30 | 5.2: dj52
31 | 6.0: dj60
32 | main: djmain
33 | qa: djqa
34 |
35 | # Normal test environment runs pytest which orchestrates other tools
36 | [testenv]
37 | deps =
38 | -r requirements.txt
39 | dj42: django>=4.2,<5
40 | dj52: django>=5.2,<6
41 | dj60: django>=6.0,<7
42 | djmain: https://github.com/django/django/archive/main.tar.gz
43 | usedevelop = true
44 | commands = pytest
45 | setenv =
46 | PYTHONDONTWRITEBYTECODE=1
47 | # Django development version is allowed to fail the test matrix
48 | ignore_outcome =
49 | djmain: True
50 | ignore_errors =
51 | djmain: True
52 |
53 | # QA runs type checks, linting, and code formatting checks
54 | [testenv:py312-djqa]
55 | deps = -r requirements.txt
56 | commands =
57 | mypy axes
58 | prospector
59 | black -t py312 --check --diff axes
60 | """
61 |
--------------------------------------------------------------------------------
/axes/migrations/0007_alter_accessattempt_unique_together.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.7 on 2021-09-13 15:16
2 |
3 | from django.db import migrations, router
4 | from django.db.models import Count
5 |
6 |
7 | def deduplicate_attempts(apps, schema_editor):
8 | AccessAttempt = apps.get_model("axes", "AccessAttempt")
9 | db_alias = schema_editor.connection.alias
10 |
11 | if db_alias != router.db_for_write(AccessAttempt):
12 | return
13 |
14 | duplicated_attempts = (
15 | AccessAttempt.objects.using(db_alias)
16 | .values("username", "user_agent", "ip_address")
17 | .annotate(Count("id"))
18 | .order_by()
19 | .filter(id__count__gt=1)
20 | )
21 |
22 | for attempt in duplicated_attempts:
23 | redundant_attempts = AccessAttempt.objects.using(db_alias).filter(
24 | username=attempt["username"],
25 | user_agent=attempt["user_agent"],
26 | ip_address=attempt["ip_address"],
27 | )[1:]
28 | for redundant_attempt in redundant_attempts:
29 | redundant_attempt.delete(using=db_alias)
30 |
31 |
32 | class Migration(migrations.Migration):
33 | dependencies = [
34 | ("axes", "0006_remove_accesslog_trusted"),
35 | ]
36 |
37 | operations = [
38 | migrations.RunPython(
39 | deduplicate_attempts, reverse_code=migrations.RunPython.noop
40 | ),
41 | migrations.AlterUniqueTogether(
42 | name="accessattempt",
43 | unique_together={("username", "ip_address", "user_agent")},
44 | ),
45 | ]
46 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | name: "Code Scanning - Action"
2 |
3 | on:
4 | pull_request:
5 |
6 | jobs:
7 | CodeQL-Build:
8 | # CodeQL runs on ubuntu-latest, windows-latest, and macos-latest
9 | runs-on: ubuntu-latest
10 |
11 | permissions:
12 | # required for all workflows
13 | security-events: write
14 |
15 | steps:
16 | - name: Checkout repository
17 | uses: actions/checkout@v6
18 |
19 | # Initializes the CodeQL tools for scanning.
20 | - name: Initialize CodeQL
21 | uses: github/codeql-action/init@v4
22 | # Override language selection by uncommenting this and choosing your languages
23 | # with:
24 | # languages: go, javascript, csharp, python, cpp, java
25 |
26 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
27 | # If this step fails, then you should remove it and run the build manually (see below).
28 | - name: Autobuild
29 | uses: github/codeql-action/autobuild@v4
30 |
31 | # ℹ️ Command-line programs to run using the OS shell.
32 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
33 |
34 | # ✏️ If the Autobuild fails above, remove it and uncomment the following
35 | # three lines and modify them (or add more) to build your code if your
36 | # project uses a compiled language
37 |
38 | #- run: |
39 | # make bootstrap
40 | # make release
41 |
42 | - name: Perform CodeQL Analysis
43 | uses: github/codeql-action/analyze@v4
44 |
--------------------------------------------------------------------------------
/axes/migrations/0002_auto_20151217_2044.py:
--------------------------------------------------------------------------------
1 | from django.db import migrations, models
2 |
3 |
4 | class Migration(migrations.Migration):
5 | dependencies = [("axes", "0001_initial")]
6 |
7 | operations = [
8 | migrations.AlterField(
9 | model_name="accessattempt",
10 | name="ip_address",
11 | field=models.GenericIPAddressField(
12 | db_index=True, null=True, verbose_name="IP Address"
13 | ),
14 | ),
15 | migrations.AlterField(
16 | model_name="accessattempt",
17 | name="trusted",
18 | field=models.BooleanField(db_index=True, default=False),
19 | ),
20 | migrations.AlterField(
21 | model_name="accessattempt",
22 | name="user_agent",
23 | field=models.CharField(db_index=True, max_length=255),
24 | ),
25 | migrations.AlterField(
26 | model_name="accessattempt",
27 | name="username",
28 | field=models.CharField(db_index=True, max_length=255, null=True),
29 | ),
30 | migrations.AlterField(
31 | model_name="accesslog",
32 | name="ip_address",
33 | field=models.GenericIPAddressField(
34 | db_index=True, null=True, verbose_name="IP Address"
35 | ),
36 | ),
37 | migrations.AlterField(
38 | model_name="accesslog",
39 | name="trusted",
40 | field=models.BooleanField(db_index=True, default=False),
41 | ),
42 | migrations.AlterField(
43 | model_name="accesslog",
44 | name="user_agent",
45 | field=models.CharField(db_index=True, max_length=255),
46 | ),
47 | migrations.AlterField(
48 | model_name="accesslog",
49 | name="username",
50 | field=models.CharField(db_index=True, max_length=255, null=True),
51 | ),
52 | ]
53 |
--------------------------------------------------------------------------------
/axes/apps.py:
--------------------------------------------------------------------------------
1 | # pylint: disable=import-outside-toplevel, unused-import
2 |
3 | from logging import getLogger
4 |
5 | from django import apps
6 |
7 | from axes import __version__
8 |
9 | log = getLogger(__name__)
10 |
11 |
12 | class AppConfig(apps.AppConfig):
13 | default_auto_field = "django.db.models.AutoField"
14 | name = "axes"
15 | initialized = False
16 |
17 | @classmethod
18 | def initialize(cls):
19 | """
20 | Initialize Axes logging and show version information.
21 |
22 | This method is re-entrant and can be called multiple times.
23 | It displays version information exactly once at application startup.
24 | """
25 |
26 | if cls.initialized:
27 | return
28 | cls.initialized = True
29 |
30 | # Only import settings, checks, and signals one time after Django has been initialized
31 | from axes.conf import settings
32 | from axes import checks, signals
33 |
34 | # Skip startup log messages if Axes is not set to verbose
35 | if settings.AXES_VERBOSE:
36 | if callable(settings.AXES_LOCKOUT_PARAMETERS) or isinstance(
37 | settings.AXES_LOCKOUT_PARAMETERS, str
38 | ):
39 | mode = "blocking by parameters that are calculated in a custom callable"
40 |
41 | else:
42 | mode = "blocking by " + " or ".join(
43 | [
44 | (
45 | param
46 | if isinstance(param, str)
47 | else "combination of " + " and ".join(param)
48 | )
49 | for param in settings.AXES_LOCKOUT_PARAMETERS
50 | ]
51 | )
52 |
53 | log.info(
54 | "AXES: BEGIN version %s, %s",
55 | __version__,
56 | mode,
57 | )
58 |
59 | def ready(self):
60 | self.initialize()
61 |
--------------------------------------------------------------------------------
/axes/signals.py:
--------------------------------------------------------------------------------
1 | from logging import getLogger
2 |
3 | from django.contrib.auth.signals import (
4 | user_logged_in,
5 | user_logged_out,
6 | user_login_failed,
7 | )
8 | from django.core.signals import setting_changed
9 | from django.db.models.signals import post_save, post_delete
10 | from django.dispatch import Signal
11 | from django.dispatch import receiver
12 |
13 | from axes.handlers.proxy import AxesProxyHandler
14 | from axes.models import AccessAttempt
15 |
16 | log = getLogger(__name__)
17 |
18 |
19 | # This signal provides the following arguments to any listeners:
20 | # request - The current Request object.
21 | # username - The username of the User who has been locked out.
22 | # ip_address - The IP of the user who has been locked out.
23 | user_locked_out = Signal()
24 |
25 |
26 | @receiver(user_login_failed)
27 | def handle_user_login_failed(*args, **kwargs):
28 | AxesProxyHandler.user_login_failed(*args, **kwargs)
29 |
30 |
31 | @receiver(user_logged_in)
32 | def handle_user_logged_in(*args, **kwargs):
33 | AxesProxyHandler.user_logged_in(*args, **kwargs)
34 |
35 |
36 | @receiver(user_logged_out)
37 | def handle_user_logged_out(*args, **kwargs):
38 | AxesProxyHandler.user_logged_out(*args, **kwargs)
39 |
40 |
41 | @receiver(post_save, sender=AccessAttempt)
42 | def handle_post_save_access_attempt(*args, **kwargs):
43 | AxesProxyHandler.post_save_access_attempt(*args, **kwargs)
44 |
45 |
46 | @receiver(post_delete, sender=AccessAttempt)
47 | def handle_post_delete_access_attempt(*args, **kwargs):
48 | AxesProxyHandler.post_delete_access_attempt(*args, **kwargs)
49 |
50 |
51 | @receiver(setting_changed)
52 | def handle_setting_changed(
53 | sender, setting, value, enter, **kwargs
54 | ): # pylint: disable=unused-argument
55 | """
56 | Reinitialize handler implementation if a relevant setting changes
57 | in e.g. application reconfiguration or during testing.
58 | """
59 |
60 | if setting == "AXES_HANDLER":
61 | AxesProxyHandler.get_implementation(force=True)
62 |
--------------------------------------------------------------------------------
/tests/test_middleware.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.http import HttpResponse, HttpRequest
3 | from django.test import override_settings
4 |
5 | from axes.middleware import AxesMiddleware
6 | from tests.base import AxesTestCase
7 |
8 |
9 | def get_username(request, credentials: dict) -> str:
10 | return credentials.get(settings.AXES_USERNAME_FORM_FIELD)
11 |
12 |
13 | class MiddlewareTestCase(AxesTestCase):
14 | STATUS_SUCCESS = 200
15 | STATUS_LOCKOUT = 429
16 |
17 | def setUp(self):
18 | self.request = HttpRequest()
19 |
20 | def test_success_response(self):
21 | def get_response(request):
22 | request.axes_locked_out = False
23 | return HttpResponse()
24 |
25 | response = AxesMiddleware(get_response)(self.request)
26 | self.assertEqual(response.status_code, self.STATUS_SUCCESS)
27 |
28 | def test_lockout_response(self):
29 | def get_response(request):
30 | request.axes_locked_out = True
31 | return HttpResponse()
32 |
33 | response = AxesMiddleware(get_response)(self.request)
34 | self.assertEqual(response.status_code, self.STATUS_LOCKOUT)
35 |
36 | @override_settings(AXES_USERNAME_CALLABLE="tests.test_middleware.get_username")
37 | def test_lockout_response_with_axes_callable_username(self):
38 | def get_response(request):
39 | request.axes_locked_out = True
40 | request.axes_credentials = {settings.AXES_USERNAME_FORM_FIELD: 'username'}
41 |
42 | return HttpResponse()
43 |
44 | response = AxesMiddleware(get_response)(self.request)
45 | self.assertEqual(response.status_code, self.STATUS_LOCKOUT)
46 |
47 | @override_settings(AXES_ENABLED=False)
48 | def test_respects_enabled_switch(self):
49 | def get_response(request):
50 | request.axes_locked_out = True
51 | return HttpResponse()
52 |
53 | response = AxesMiddleware(get_response)(self.request)
54 | self.assertEqual(response.status_code, self.STATUS_SUCCESS)
55 |
--------------------------------------------------------------------------------
/tests/test_decorators.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import MagicMock, patch
2 |
3 | from django.http import HttpResponse
4 |
5 | from axes.decorators import axes_dispatch, axes_form_invalid
6 | from tests.base import AxesTestCase
7 |
8 |
9 | class DecoratorTestCase(AxesTestCase):
10 | SUCCESS_RESPONSE = HttpResponse(status=200, content="Dispatched")
11 | LOCKOUT_RESPONSE = HttpResponse(status=429, content="Locked out")
12 |
13 | def setUp(self):
14 | self.request = MagicMock()
15 | self.cls = MagicMock(return_value=self.request)
16 | self.func = MagicMock(return_value=self.SUCCESS_RESPONSE)
17 |
18 | @patch("axes.handlers.proxy.AxesProxyHandler.is_allowed", return_value=False)
19 | @patch("axes.decorators.get_lockout_response", return_value=LOCKOUT_RESPONSE)
20 | def test_axes_dispatch_locks_out(self, _, __):
21 | response = axes_dispatch(self.func)(self.request)
22 | self.assertEqual(response.content, self.LOCKOUT_RESPONSE.content)
23 |
24 | @patch("axes.handlers.proxy.AxesProxyHandler.is_allowed", return_value=True)
25 | @patch("axes.decorators.get_lockout_response", return_value=LOCKOUT_RESPONSE)
26 | def test_axes_dispatch_dispatches(self, _, __):
27 | response = axes_dispatch(self.func)(self.request)
28 | self.assertEqual(response.content, self.SUCCESS_RESPONSE.content)
29 |
30 | @patch("axes.handlers.proxy.AxesProxyHandler.is_allowed", return_value=False)
31 | @patch("axes.decorators.get_lockout_response", return_value=LOCKOUT_RESPONSE)
32 | def test_axes_form_invalid_locks_out(self, _, __):
33 | response = axes_form_invalid(self.func)(self.cls)
34 | self.assertEqual(response.content, self.LOCKOUT_RESPONSE.content)
35 |
36 | @patch("axes.handlers.proxy.AxesProxyHandler.is_allowed", return_value=True)
37 | @patch("axes.decorators.get_lockout_response", return_value=LOCKOUT_RESPONSE)
38 | def test_axes_form_invalid_dispatches(self, _, __):
39 | response = axes_form_invalid(self.func)(self.cls)
40 | self.assertEqual(response.content, self.SUCCESS_RESPONSE.content)
41 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on: [push, pull_request]
4 |
5 | permissions:
6 | contents: read
7 |
8 | jobs:
9 | build:
10 | name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }})
11 | runs-on: ubuntu-latest
12 | strategy:
13 | fail-fast: false
14 | matrix:
15 | python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
16 | django-version: ['4.2', '5.2', '6.0']
17 | include:
18 | # Tox configuration for QA environment
19 | - python-version: '3.14'
20 | django-version: 'qa'
21 | # Django main
22 | - python-version: '3.14'
23 | django-version: 'main'
24 | experimental: true
25 | exclude:
26 | - python-version: '3.13'
27 | django-version: '4.2'
28 | - python-version: '3.9'
29 | django-version: '5.2'
30 | - python-version: '3.10'
31 | django-version: '6.0'
32 | - python-version: '3.11'
33 | django-version: '6.0'
34 |
35 | steps:
36 | - uses: actions/checkout@v6
37 |
38 | - name: Set up Python ${{ matrix.python-version }}
39 | uses: actions/setup-python@v6
40 | with:
41 | python-version: ${{ matrix.python-version }}
42 |
43 | - name: Get pip cache dir
44 | id: pip-cache
45 | run: |
46 | echo "::set-output name=dir::$(pip cache dir)"
47 |
48 | - name: Cache
49 | uses: actions/cache@v5
50 | with:
51 | path: ${{ steps.pip-cache.outputs.dir }}
52 | key:
53 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}
54 | restore-keys: |
55 | ${{ matrix.python-version }}-v1-
56 |
57 | - name: Install dependencies
58 | run: |
59 | python -m pip install --upgrade pip
60 | python -m pip install --upgrade tox tox-gh-actions
61 |
62 | - name: Tox tests
63 | run: |
64 | tox -v
65 | env:
66 | DJANGO: ${{ matrix.django-version }}
67 |
68 | - name: Upload coverage
69 | uses: codecov/codecov-action@v3
70 | with:
71 | name: Python ${{ matrix.python-version }}
72 |
--------------------------------------------------------------------------------
/axes/migrations/0003_auto_20160322_0929.py:
--------------------------------------------------------------------------------
1 | from django.db import models, migrations
2 |
3 |
4 | class Migration(migrations.Migration):
5 | dependencies = [("axes", "0002_auto_20151217_2044")]
6 |
7 | operations = [
8 | migrations.AlterField(
9 | model_name="accessattempt",
10 | name="failures_since_start",
11 | field=models.PositiveIntegerField(verbose_name="Failed Logins"),
12 | ),
13 | migrations.AlterField(
14 | model_name="accessattempt",
15 | name="get_data",
16 | field=models.TextField(verbose_name="GET Data"),
17 | ),
18 | migrations.AlterField(
19 | model_name="accessattempt",
20 | name="http_accept",
21 | field=models.CharField(verbose_name="HTTP Accept", max_length=1025),
22 | ),
23 | migrations.AlterField(
24 | model_name="accessattempt",
25 | name="ip_address",
26 | field=models.GenericIPAddressField(
27 | null=True, verbose_name="IP Address", db_index=True
28 | ),
29 | ),
30 | migrations.AlterField(
31 | model_name="accessattempt",
32 | name="path_info",
33 | field=models.CharField(verbose_name="Path", max_length=255),
34 | ),
35 | migrations.AlterField(
36 | model_name="accessattempt",
37 | name="post_data",
38 | field=models.TextField(verbose_name="POST Data"),
39 | ),
40 | migrations.AlterField(
41 | model_name="accesslog",
42 | name="http_accept",
43 | field=models.CharField(verbose_name="HTTP Accept", max_length=1025),
44 | ),
45 | migrations.AlterField(
46 | model_name="accesslog",
47 | name="ip_address",
48 | field=models.GenericIPAddressField(
49 | null=True, verbose_name="IP Address", db_index=True
50 | ),
51 | ),
52 | migrations.AlterField(
53 | model_name="accesslog",
54 | name="path_info",
55 | field=models.CharField(verbose_name="Path", max_length=255),
56 | ),
57 | ]
58 |
--------------------------------------------------------------------------------
/axes/locale/tr/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2018-07-17 15:56+0200\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: FULL NAME \n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
20 |
21 | #: axes/admin.py:38
22 | msgid "Form Data"
23 | msgstr "Form-Verisi"
24 |
25 | #: axes/admin.py:41 axes/admin.py:95
26 | msgid "Meta Data"
27 | msgstr "Meta-Verisi"
28 |
29 | #: axes/conf.py:58
30 | msgid "Account locked: too many login attempts. Please try again later."
31 | msgstr ""
32 | "Hesap kilitlendi: cok fazla erişim denemesi. Lütfen daha sonra tekrar deneyiniz."
33 |
34 | #: axes/conf.py:61
35 | msgid ""
36 | "Account locked: too many login attempts. Contact an admin to unlock your "
37 | "account."
38 | msgstr ""
39 | "Hesap kilitlendi: cok fazla erişim denemesi. Hesabını açtırmak için yöneticiyle iletişime"
40 | "geçin."
41 |
42 | #: axes/models.py:9
43 | msgid "User Agent"
44 | msgstr ""
45 |
46 | #: axes/models.py:15
47 | msgid "IP Address"
48 | msgstr "IP-Adresi"
49 |
50 | #: axes/models.py:21
51 | msgid "Username"
52 | msgstr "Kullanıcı Adı"
53 |
54 | #: axes/models.py:35
55 | msgid "HTTP Accept"
56 | msgstr ""
57 |
58 | #: axes/models.py:40
59 | msgid "Path"
60 | msgstr "Yol"
61 |
62 | #: axes/models.py:45
63 | msgid "Attempt Time"
64 | msgstr "Girişim Zamanı"
65 |
66 | #: axes/models.py:57
67 | msgid "GET Data"
68 | msgstr "GET-Verisi"
69 |
70 | #: axes/models.py:61
71 | msgid "POST Data"
72 | msgstr "POST-Verisi"
73 |
74 | #: axes/models.py:65
75 | msgid "Failed Logins"
76 | msgstr "Geçersiz Girişler"
77 |
78 | #: axes/models.py:76
79 | msgid "access attempt"
80 | msgstr "erişim denemesi"
81 |
82 | #: axes/models.py:77
83 | msgid "access attempts"
84 | msgstr "erişim denemeleri"
85 |
86 | #: axes/models.py:81
87 | msgid "Logout Time"
88 | msgstr "Çıkış Zamanı"
89 |
90 | #: axes/models.py:90
91 | msgid "access log"
92 | msgstr "erişim kaydı"
93 |
94 | #: axes/models.py:91
95 | msgid "access logs"
96 | msgstr "erişim kayıtları"
97 |
--------------------------------------------------------------------------------
/axes/migrations/0004_auto_20181024_1538.py:
--------------------------------------------------------------------------------
1 | from django.db import migrations, models
2 |
3 |
4 | class Migration(migrations.Migration):
5 | dependencies = [("axes", "0003_auto_20160322_0929")]
6 |
7 | operations = [
8 | migrations.AlterModelOptions(
9 | name="accessattempt",
10 | options={
11 | "verbose_name": "access attempt",
12 | "verbose_name_plural": "access attempts",
13 | },
14 | ),
15 | migrations.AlterModelOptions(
16 | name="accesslog",
17 | options={
18 | "verbose_name": "access log",
19 | "verbose_name_plural": "access logs",
20 | },
21 | ),
22 | migrations.AlterField(
23 | model_name="accessattempt",
24 | name="attempt_time",
25 | field=models.DateTimeField(auto_now_add=True, verbose_name="Attempt Time"),
26 | ),
27 | migrations.AlterField(
28 | model_name="accessattempt",
29 | name="user_agent",
30 | field=models.CharField(
31 | db_index=True, max_length=255, verbose_name="User Agent"
32 | ),
33 | ),
34 | migrations.AlterField(
35 | model_name="accessattempt",
36 | name="username",
37 | field=models.CharField(
38 | db_index=True, max_length=255, null=True, verbose_name="Username"
39 | ),
40 | ),
41 | migrations.AlterField(
42 | model_name="accesslog",
43 | name="attempt_time",
44 | field=models.DateTimeField(auto_now_add=True, verbose_name="Attempt Time"),
45 | ),
46 | migrations.AlterField(
47 | model_name="accesslog",
48 | name="logout_time",
49 | field=models.DateTimeField(
50 | blank=True, null=True, verbose_name="Logout Time"
51 | ),
52 | ),
53 | migrations.AlterField(
54 | model_name="accesslog",
55 | name="user_agent",
56 | field=models.CharField(
57 | db_index=True, max_length=255, verbose_name="User Agent"
58 | ),
59 | ),
60 | migrations.AlterField(
61 | model_name="accesslog",
62 | name="username",
63 | field=models.CharField(
64 | db_index=True, max_length=255, null=True, verbose_name="Username"
65 | ),
66 | ),
67 | ]
68 |
--------------------------------------------------------------------------------
/axes/migrations/0008_accessfailurelog.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.12 on 2022-03-15 03:00
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("axes", "0007_alter_accessattempt_unique_together"),
9 | ]
10 |
11 | operations = [
12 | migrations.CreateModel(
13 | name="AccessFailureLog",
14 | fields=[
15 | (
16 | "id",
17 | models.AutoField(
18 | auto_created=True,
19 | primary_key=True,
20 | serialize=False,
21 | verbose_name="ID",
22 | ),
23 | ),
24 | (
25 | "user_agent",
26 | models.CharField(
27 | db_index=True, max_length=255, verbose_name="User Agent"
28 | ),
29 | ),
30 | (
31 | "ip_address",
32 | models.GenericIPAddressField(
33 | db_index=True, null=True, verbose_name="IP Address"
34 | ),
35 | ),
36 | (
37 | "username",
38 | models.CharField(
39 | db_index=True,
40 | max_length=255,
41 | null=True,
42 | verbose_name="Username",
43 | ),
44 | ),
45 | (
46 | "http_accept",
47 | models.CharField(max_length=1025, verbose_name="HTTP Accept"),
48 | ),
49 | ("path_info", models.CharField(max_length=255, verbose_name="Path")),
50 | (
51 | "attempt_time",
52 | models.DateTimeField(
53 | auto_now_add=True, verbose_name="Attempt Time"
54 | ),
55 | ),
56 | (
57 | "locked_out",
58 | models.BooleanField(
59 | blank=True, default=False, verbose_name="Access lock out"
60 | ),
61 | ),
62 | ],
63 | options={
64 | "verbose_name": "access failure",
65 | "verbose_name_plural": "access failures",
66 | },
67 | ),
68 | ]
69 |
--------------------------------------------------------------------------------
/axes/utils.py:
--------------------------------------------------------------------------------
1 | """
2 | Axes utility functions that are publicly available.
3 |
4 | This module is separate for historical reasons
5 | and offers a backwards compatible import path.
6 | """
7 |
8 | from logging import getLogger
9 | from typing import Optional
10 |
11 | from django.http import HttpRequest
12 |
13 | from axes.handlers.proxy import AxesProxyHandler
14 | from axes.helpers import get_client_ip_address, get_lockout_parameters
15 |
16 | log = getLogger(__name__)
17 |
18 |
19 | def reset(
20 | ip: Optional[str] = None, username: Optional[str] = None, ip_or_username=False
21 | ) -> int:
22 | """
23 | Reset records that match IP or username, and return the count of removed attempts.
24 |
25 | This utility method is meant to be used from the CLI or via Python API.
26 | """
27 |
28 | return AxesProxyHandler.reset_attempts(
29 | ip_address=ip, username=username, ip_or_username=ip_or_username
30 | )
31 |
32 |
33 | def reset_request(request: HttpRequest) -> int:
34 | """
35 | Reset records that match IP or username, and return the count of removed attempts.
36 |
37 | This utility method is meant to be used from the CLI or via Python API.
38 | """
39 | lockout_paramaters = get_lockout_parameters(request)
40 |
41 | ip: Optional[str] = get_client_ip_address(request)
42 | username = request.GET.get("username", None)
43 |
44 | ip_required = False
45 | username_required = False
46 | ip_and_username = False
47 |
48 | for param in lockout_paramaters:
49 | # hack: in works with all iterables, including strings
50 | # so this checks works with separate parameters
51 | # and with parameters combinations
52 | if "username" in param and "ip_address" in param:
53 | ip_and_username = True
54 | ip_required = True
55 | username_required = True
56 | break
57 | if "username" in param:
58 | username_required = True
59 | elif "ip_address" in param:
60 | ip_required = True
61 |
62 | ip_or_username = not ip_and_username and ip_required and username_required
63 | if not ip_required:
64 | ip = None
65 | if not username_required:
66 | username = None
67 |
68 | if not ip and not username:
69 | return 0
70 | # We don't want to reset everything, if there is some wrong request parameter
71 |
72 | # TODO: reset based on user_agent?
73 | return reset(ip, username, ip_or_username)
74 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Code of Conduct
2 |
3 | As contributors and maintainers of the Jazzband projects, and in the interest of
4 | fostering an open and welcoming community, we pledge to respect all people who
5 | contribute through reporting issues, posting feature requests, updating documentation,
6 | submitting pull requests or patches, and other activities.
7 |
8 | We are committed to making participation in the Jazzband a harassment-free experience
9 | for everyone, regardless of the level of experience, gender, gender identity and
10 | expression, sexual orientation, disability, personal appearance, body size, race,
11 | ethnicity, age, religion, or nationality.
12 |
13 | Examples of unacceptable behavior by participants include:
14 |
15 | - The use of sexualized language or imagery
16 | - Personal attacks
17 | - Trolling or insulting/derogatory comments
18 | - Public or private harassment
19 | - Publishing other's private information, such as physical or electronic addresses,
20 | without explicit permission
21 | - Other unethical or unprofessional conduct
22 |
23 | The Jazzband roadies have the right and responsibility to remove, edit, or reject
24 | comments, commits, code, wiki edits, issues, and other contributions that are not
25 | aligned to this Code of Conduct, or to ban temporarily or permanently any contributor
26 | for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
27 |
28 | By adopting this Code of Conduct, the roadies commit themselves to fairly and
29 | consistently applying these principles to every aspect of managing the jazzband
30 | projects. Roadies who do not follow or enforce the Code of Conduct may be permanently
31 | removed from the Jazzband roadies.
32 |
33 | This code of conduct applies both within project spaces and in public spaces when an
34 | individual is representing the project or its community.
35 |
36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by
37 | contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and
38 | investigated and will result in a response that is deemed necessary and appropriate to
39 | the circumstances. Roadies are obligated to maintain confidentiality with regard to the
40 | reporter of an incident.
41 |
42 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version
43 | 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version]
44 |
45 | [homepage]: https://contributor-covenant.org
46 | [version]: https://contributor-covenant.org/version/1/3/0/
47 |
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
2 |
3 | CACHES = {
4 | "default": {
5 | # This cache backend is OK to use in development and testing
6 | # but has the potential to break production setups with more than on process
7 | # due to each process having their own local memory based cache
8 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache"
9 | }
10 | }
11 |
12 | SITE_ID = 1
13 |
14 | MIDDLEWARE = [
15 | "django.middleware.common.CommonMiddleware",
16 | "django.contrib.sessions.middleware.SessionMiddleware",
17 | "django.contrib.auth.middleware.AuthenticationMiddleware",
18 | "django.contrib.messages.middleware.MessageMiddleware",
19 | "axes.middleware.AxesMiddleware",
20 | ]
21 |
22 | AUTHENTICATION_BACKENDS = [
23 | "axes.backends.AxesStandaloneBackend",
24 | "django.contrib.auth.backends.ModelBackend",
25 | ]
26 |
27 | # Use MD5 for tests as it is considerably faster than other options
28 | # note that this should never be used in any online setting
29 | # where users actually log in to the system due to easy exploitability
30 | PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
31 |
32 | ROOT_URLCONF = "tests.urls"
33 |
34 | INSTALLED_APPS = [
35 | "django.contrib.auth",
36 | "django.contrib.contenttypes",
37 | "django.contrib.sessions",
38 | "django.contrib.sites",
39 | "django.contrib.messages",
40 | "django.contrib.admin",
41 | "axes",
42 | ]
43 |
44 | TEMPLATES = [
45 | {
46 | "BACKEND": "django.template.backends.django.DjangoTemplates",
47 | "DIRS": [],
48 | "APP_DIRS": True,
49 | "OPTIONS": {
50 | "context_processors": [
51 | "django.template.context_processors.debug",
52 | "django.template.context_processors.request",
53 | "django.contrib.auth.context_processors.auth",
54 | "django.contrib.messages.context_processors.messages",
55 | ]
56 | },
57 | }
58 | ]
59 |
60 | LOGGING = {
61 | "version": 1,
62 | "disable_existing_loggers": False,
63 | "handlers": {"console": {"class": "logging.StreamHandler"}},
64 | "loggers": {"axes": {"handlers": ["console"], "level": "INFO", "propagate": False}},
65 | }
66 |
67 | SECRET_KEY = "too-secret-for-test"
68 |
69 | USE_I18N = False
70 |
71 | USE_TZ = False
72 |
73 | LOGIN_REDIRECT_URL = "/admin/"
74 |
75 | AXES_FAILURE_LIMIT = 10
76 |
77 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
78 |
--------------------------------------------------------------------------------
/CONTRIBUTING.rst:
--------------------------------------------------------------------------------
1 | .. image:: https://jazzband.co/static/img/jazzband.svg
2 | :target: https://jazzband.co/
3 | :alt: Jazzband
4 |
5 | This is a `Jazzband `_ project. By contributing you agree to abide by the `Contributor Code of Conduct `_ and follow the `guidelines `_.
6 |
7 |
8 | Contributions
9 | =============
10 |
11 | All contributions are welcome!
12 |
13 | It is best to separate proposed changes and PRs into small, distinct patches
14 | by type so that they can be merged faster into upstream and released quicker.
15 |
16 | One way to organize contributions would be to separate PRs for e.g.
17 |
18 | * bugfixes,
19 | * new features,
20 | * code and design improvements,
21 | * documentation improvements, or
22 | * tooling and CI improvements.
23 |
24 | Merging contributions requires passing the checks configured
25 | with the CI. This includes running tests and linters successfully
26 | on the currently officially supported Python and Django versions.
27 |
28 |
29 | Development
30 | ===========
31 |
32 | You can contribute to this project forking it from GitHub and sending pull requests.
33 |
34 | First `fork `_ the
35 | `repository `_ and then clone it::
36 |
37 | $ git clone git@github.com:/django-axes.git
38 |
39 | Initialize a virtual environment for development purposes::
40 |
41 | $ mkdir -p ~/.virtualenvs
42 | $ python3 -m venv ~/.virtualenvs/django-axes
43 | $ source ~/.virtualenvs/django-axes/bin/activate
44 |
45 | Then install the necessary requirements::
46 |
47 | $ cd django-axes
48 | $ pip install -r requirements.txt
49 |
50 | Unit tests are located in the ``axes/tests`` folder and can be easily run with the pytest tool::
51 |
52 | $ pytest
53 |
54 | Prospector runs a number of source code style, safety, and complexity checks::
55 |
56 | $ prospector
57 |
58 | Mypy runs static typing checks to verify the source code type annotations and correctness::
59 |
60 | $ mypy .
61 |
62 | Before committing, you can run all the above tests against all supported Python and Django versions with tox::
63 |
64 | $ tox
65 |
66 | Tox runs the same test set that is run by GitHub Actions, and your code should be good to go if it passes.
67 |
68 | If you wish to limit the testing to specific environment(s), you can parametrize the tox run::
69 |
70 | $ tox -e py39-django32
71 |
72 | After you have pushed your changes, open a pull request on GitHub for getting your code upstreamed.
73 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | from setuptools import setup, find_packages
4 |
5 | setup(
6 | name="django-axes",
7 | description="Keep track of failed login attempts in Django-powered sites.",
8 | long_description="\n".join(
9 | [
10 | open("README.rst", encoding="utf-8").read(),
11 | open("CHANGES.rst", encoding="utf-8").read(),
12 | ]
13 | ),
14 | keywords="authentication django pci security",
15 | author=", ".join(
16 | [
17 | "Josh VanderLinden",
18 | "Philip Neustrom",
19 | "Michael Blume",
20 | "Alex Clark",
21 | "Camilo Nova",
22 | "Aleksi Hakli",
23 | ]
24 | ),
25 | author_email="security@jazzband.co",
26 | maintainer="Jazzband",
27 | maintainer_email="security@jazzband.co",
28 | url="https://github.com/jazzband/django-axes",
29 | project_urls={
30 | "Documentation": "https://django-axes.readthedocs.io/",
31 | "Source": "https://github.com/jazzband/django-axes",
32 | "Tracker": "https://github.com/jazzband/django-axes/issues",
33 | },
34 | license="MIT",
35 | package_dir={"axes": "axes"},
36 | use_scm_version=True,
37 | setup_requires=["setuptools_scm"],
38 | python_requires=">=3.10",
39 | install_requires=[
40 | "django>=4.2",
41 | "asgiref>=3.6.0",
42 | ],
43 | extras_require={
44 | "ipware": "django-ipware>=3",
45 | },
46 | include_package_data=True,
47 | packages=find_packages(exclude=["tests"]),
48 | classifiers=[
49 | "Development Status :: 5 - Production/Stable",
50 | "Environment :: Web Environment",
51 | "Environment :: Plugins",
52 | "Framework :: Django",
53 | "Framework :: Django :: 4.2",
54 | "Framework :: Django :: 5.2",
55 | "Framework :: Django :: 6.0",
56 | "Intended Audience :: Developers",
57 | "Intended Audience :: System Administrators",
58 | "License :: OSI Approved :: MIT License",
59 | "Operating System :: OS Independent",
60 | "Programming Language :: Python",
61 | "Programming Language :: Python :: 3",
62 | "Programming Language :: Python :: 3.10",
63 | "Programming Language :: Python :: 3.11",
64 | "Programming Language :: Python :: 3.12",
65 | "Programming Language :: Python :: 3.13",
66 | "Programming Language :: Python :: 3.14",
67 | "Programming Language :: Python :: Implementation :: CPython",
68 | "Topic :: Internet :: Log Analysis",
69 | "Topic :: Security",
70 | "Topic :: System :: Logging",
71 | ],
72 | zip_safe=False,
73 | )
74 |
--------------------------------------------------------------------------------
/axes/middleware.py:
--------------------------------------------------------------------------------
1 | from typing import Callable
2 |
3 | from asgiref.sync import iscoroutinefunction, markcoroutinefunction, sync_to_async
4 | from django.conf import settings
5 | from django.http import HttpRequest, HttpResponse
6 |
7 | from axes.helpers import get_lockout_response
8 |
9 |
10 | class AxesMiddleware:
11 | """
12 | Middleware that calculates necessary HTTP request attributes for attempt monitoring
13 | and maps lockout signals into readable HTTP 403 Forbidden responses.
14 |
15 | If a project uses ``django rest framework`` then the middleware updates the
16 | request and checks whether the limit has been exceeded. It's needed only
17 | for integration with DRF because it uses its own request object.
18 |
19 | This middleware recognizes a logout monitoring flag in the request and
20 | and uses the ``axes.helpers.get_lockout_response`` handler for returning
21 | customizable and context aware lockout message to the end user if necessary.
22 |
23 | To customize the lockout handling behaviour further, you can subclass this middleware
24 | and change the ``__call__`` method to your own liking.
25 |
26 | Please see the following configuration flags before customizing this handler:
27 |
28 | - ``AXES_LOCKOUT_TEMPLATE``,
29 | - ``AXES_LOCKOUT_URL``,
30 | - ``AXES_COOLOFF_MESSAGE``, and
31 | - ``AXES_PERMALOCK_MESSAGE``.
32 | """
33 |
34 | async_capable = True
35 | sync_capable = True
36 |
37 | def __init__(self, get_response: Callable) -> None:
38 | self.get_response = get_response
39 | if iscoroutinefunction(self.get_response):
40 | markcoroutinefunction(self)
41 |
42 | def __call__(self, request: HttpRequest) -> HttpResponse:
43 | # Exit out to async mode, if needed
44 | if iscoroutinefunction(self):
45 | return self.__acall__(request)
46 |
47 | response = self.get_response(request)
48 | if settings.AXES_ENABLED:
49 | if getattr(request, "axes_locked_out", None):
50 | credentials = getattr(request, "axes_credentials", None)
51 | response = get_lockout_response(request, response, credentials) # type: ignore
52 |
53 | return response
54 |
55 | async def __acall__(self, request: HttpRequest) -> HttpResponse:
56 | response = await self.get_response(request)
57 |
58 | if settings.AXES_ENABLED:
59 | if getattr(request, "axes_locked_out", None):
60 | credentials = getattr(request, "axes_credentials", None)
61 | response = await sync_to_async(
62 | get_lockout_response, thread_sensitive=True
63 | )(request, credentials) # type: ignore
64 |
65 | return response
66 |
--------------------------------------------------------------------------------
/axes/locale/pl/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: \n"
9 | "Report-Msgid-Bugs-To: \n"
10 | "POT-Creation-Date: 2021-06-11 23:36+0200\n"
11 | "PO-Revision-Date: 2021-06-16 10:51+0300\n"
12 | "Language: pl\n"
13 | "MIME-Version: 1.0\n"
14 | "Content-Type: text/plain; charset=UTF-8\n"
15 | "Content-Transfer-Encoding: 8bit\n"
16 | "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n"
17 | "%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n"
18 | "%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
19 | "Last-Translator: \n"
20 | "Language-Team: \n"
21 | "X-Generator: Poedit 3.0\n"
22 |
23 | #: .\axes\admin.py:26
24 | msgid "Form Data"
25 | msgstr "Dane formularza"
26 |
27 | #: .\axes\admin.py:27 .\axes\admin.py:64
28 | msgid "Meta Data"
29 | msgstr "Metadane"
30 |
31 | #: .\axes\conf.py:89
32 | msgid "Account locked: too many login attempts. Please try again later."
33 | msgstr ""
34 | "Konto zablokowane: zbyt wiele prób logowania. Spróbuj ponownie później."
35 |
36 | #: .\axes\conf.py:97
37 | msgid ""
38 | "Account locked: too many login attempts. Contact an admin to unlock your "
39 | "account."
40 | msgstr ""
41 | "Konto zablokowane: zbyt wiele prób logowania. Skontaktuj się z "
42 | "administratorem, aby odblokować swoje konto."
43 |
44 | #: .\axes\models.py:6
45 | #, fuzzy
46 | msgid "User Agent"
47 | msgstr "User Agent"
48 |
49 | #: .\axes\models.py:8
50 | msgid "IP Address"
51 | msgstr "Adres IP"
52 |
53 | #: .\axes\models.py:10
54 | msgid "Username"
55 | msgstr "Nazwa Użytkownika"
56 |
57 | #: .\axes\models.py:12
58 | #, fuzzy
59 | msgid "HTTP Accept"
60 | msgstr "HTTP Accept"
61 |
62 | #: .\axes\models.py:14
63 | msgid "Path"
64 | msgstr "Ścieżka"
65 |
66 | #: .\axes\models.py:16
67 | msgid "Attempt Time"
68 | msgstr "Czas wystąpienia"
69 |
70 | #: .\axes\models.py:25
71 | msgid "GET Data"
72 | msgstr "Dane GET"
73 |
74 | #: .\axes\models.py:27
75 | msgid "POST Data"
76 | msgstr "Dane POST"
77 |
78 | #: .\axes\models.py:29
79 | msgid "Failed Logins"
80 | msgstr "Nieudane logowania"
81 |
82 | #: .\axes\models.py:35
83 | msgid "access attempt"
84 | msgstr "próba dostępu"
85 |
86 | #: .\axes\models.py:36
87 | msgid "access attempts"
88 | msgstr "próby dostępu"
89 |
90 | #: .\axes\models.py:40
91 | msgid "Logout Time"
92 | msgstr "Czas wylogowania"
93 |
94 | #: .\axes\models.py:46
95 | msgid "access log"
96 | msgstr "dziennik logowania"
97 |
98 | #: .\axes\models.py:47
99 | msgid "access logs"
100 | msgstr "dzienniki logowania"
101 |
--------------------------------------------------------------------------------
/axes/locale/ar/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2022-05-30 15:16+0300\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: FULL NAME \n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
20 | "&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
21 |
22 | #: admin.py:27
23 | msgid "Form Data"
24 | msgstr "بيانات النموذج"
25 |
26 | #: admin.py:28 admin.py:65 admin.py:100
27 | msgid "Meta Data"
28 | msgstr "البيانات الوصفية"
29 |
30 | #: conf.py:97
31 | msgid "Account locked: too many login attempts. Please try again later."
32 | msgstr "الحساب مغلق: محاولات تسجيل دخول كثيرة جدًا. الرجاء معاودة المحاولة في وقت لاحق."
33 |
34 | #: conf.py:105
35 | msgid ""
36 | "Account locked: too many login attempts. Contact an admin to unlock your "
37 | "account."
38 | msgstr "الحساب مغلق: محاولات تسجيل دخول كثيرة جدًا. اتصل بمسؤول لفتح حسابك."
39 |
40 | #: models.py:6
41 | msgid "User Agent"
42 | msgstr "وكيل المستخدم"
43 |
44 | #: models.py:8
45 | msgid "IP Address"
46 | msgstr "عنوان IP"
47 |
48 | #: models.py:10
49 | msgid "Username"
50 | msgstr "اسم المستخدم"
51 |
52 | #: models.py:12
53 | msgid "HTTP Accept"
54 | msgstr "قبول HTTP"
55 |
56 | #: models.py:14
57 | msgid "Path"
58 | msgstr "معلومات المسار"
59 |
60 | #: models.py:16
61 | msgid "Attempt Time"
62 | msgstr "وقت المحاولة"
63 |
64 | #: models.py:26
65 | msgid "Access lock out"
66 | msgstr "مقيد من الدخول"
67 |
68 | #: models.py:34
69 | msgid "access failure"
70 | msgstr "سجل دخول فاشلة"
71 |
72 | #: models.py:35
73 | msgid "access failures"
74 | msgstr "سجلات دخول فاشلة"
75 |
76 | #: models.py:39
77 | msgid "GET Data"
78 | msgstr "GET بيانات"
79 |
80 | #: models.py:41
81 | msgid "POST Data"
82 | msgstr "POST بيانات"
83 |
84 | #: models.py:43
85 | msgid "Failed Logins"
86 | msgstr "عمليات تسجيل دخول فاشلة"
87 |
88 | #: models.py:49
89 | msgid "access attempt"
90 | msgstr "محاولة دخول"
91 |
92 | #: models.py:50
93 | msgid "access attempts"
94 | msgstr "محاولات دخول"
95 |
96 | #: models.py:55
97 | msgid "Logout Time"
98 | msgstr "وقت تسجيل الخروج"
99 |
100 | #: models.py:61
101 | msgid "access log"
102 | msgstr "سجل الدخول"
103 |
104 | #: models.py:62
105 | msgid "access logs"
106 | msgstr "سجلات الدخول"
107 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 |
2 | django-axes
3 | ===========
4 |
5 | .. image:: https://jazzband.co/static/img/badge.svg
6 | :target: https://jazzband.co/
7 | :alt: Jazzband
8 |
9 | .. image:: https://img.shields.io/github/stars/jazzband/django-axes.svg?label=Stars&style=socialcA
10 | :target: https://github.com/jazzband/django-axes
11 | :alt: GitHub
12 |
13 | .. image:: https://img.shields.io/pypi/v/django-axes.svg
14 | :target: https://pypi.org/project/django-axes/
15 | :alt: PyPI release
16 |
17 | .. image:: https://img.shields.io/pypi/pyversions/django-axes.svg
18 | :target: https://pypi.org/project/django-axes/
19 | :alt: Supported Python versions
20 |
21 | .. image:: https://img.shields.io/pypi/djversions/django-axes.svg
22 | :target: https://pypi.org/project/django-axes/
23 | :alt: Supported Django versions
24 |
25 | .. image:: https://img.shields.io/readthedocs/django-axes.svg
26 | :target: https://django-axes.readthedocs.io/
27 | :alt: Documentation
28 |
29 | .. image:: https://github.com/jazzband/django-axes/workflows/Test/badge.svg
30 | :target: https://github.com/jazzband/django-axes/actions
31 | :alt: GitHub Actions
32 |
33 | .. image:: https://codecov.io/gh/jazzband/django-axes/branch/master/graph/badge.svg
34 | :target: https://codecov.io/gh/jazzband/django-axes
35 | :alt: Coverage
36 |
37 |
38 | Axes is a Django plugin for keeping track of suspicious
39 | login attempts for your Django based website
40 | and implementing simple brute-force attack blocking.
41 |
42 | The name is sort of a geeky pun, since it can be interpreted as:
43 |
44 | * ``access``, as in monitoring access attempts, or
45 | * ``axes``, as in tools you can use to hack (generally on wood).
46 |
47 |
48 | Functionality
49 | -------------
50 |
51 | Axes records login attempts to your Django powered site and prevents attackers
52 | from attempting further logins to your site when they exceed the configured attempt limit.
53 |
54 | Axes can track the attempts and persist them in the database indefinitely,
55 | or alternatively use a fast and DDoS resistant cache implementation.
56 |
57 | Axes can be configured to monitor login attempts by
58 | IP address, username, user agent, or their combinations.
59 |
60 | Axes supports cool off periods, IP address allow listing and block listing,
61 | user account allow listing, and other features for Django access management.
62 |
63 |
64 | Documentation
65 | -------------
66 |
67 | For more information on installation and configuration see the documentation at:
68 |
69 | https://django-axes.readthedocs.io/
70 |
71 |
72 | Issues
73 | ------
74 |
75 | If you have questions or have trouble using the app please file a bug report at:
76 |
77 | https://github.com/jazzband/django-axes/issues
78 |
79 |
80 | Contributing
81 | ------------
82 |
83 | See `CONTRIBUTING `__.
84 |
--------------------------------------------------------------------------------
/axes/locale/id/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # Indonesian translation for django-axes.
2 | # Copyright (C) 2023
3 | # This file is distributed under the same license as the django-axes package.
4 | # Kira , 2023.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: django-axes 6.0.4\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2023-06-30 09:21+0800\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: Kira \n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: id\n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Plural-Forms: nplurals=1; plural=0;\n"
20 | #: .\axes\admin.py:27
21 | msgid "Form Data"
22 | msgstr "Data Formulir"
23 |
24 | #: .\axes\admin.py:28 .\axes\admin.py:65 .\axes\admin.py:100
25 | msgid "Meta Data"
26 | msgstr "Meta Data"
27 |
28 | #: .\axes\conf.py:108
29 | msgid "Account locked: too many login attempts. Please try again later."
30 | msgstr "Akun terkunci: terlalu banyak percobaan login. Silakan coba lagi nanti."
31 |
32 | #: .\axes\conf.py:116
33 | msgid ""
34 | "Account locked: too many login attempts. Contact an admin to unlock your "
35 | "account."
36 | msgstr "Akun terkunci: terlalu banyak percobaan login. Hubungi admin untuk"
37 | " membuka kunci akun"
38 |
39 | #: .\axes\models.py:6
40 | msgid "User Agent"
41 | msgstr "User Agent"
42 |
43 | #: .\axes\models.py:8
44 | msgid "IP Address"
45 | msgstr "Alamat IP"
46 |
47 | #: .\axes\models.py:10
48 | msgid "Username"
49 | msgstr "Nama Pengguna"
50 |
51 | #: .\axes\models.py:12
52 | msgid "HTTP Accept"
53 | msgstr "HTTP Accept"
54 |
55 | #: .\axes\models.py:14
56 | msgid "Path"
57 | msgstr "Path"
58 |
59 | #: .\axes\models.py:16
60 | msgid "Attempt Time"
61 | msgstr "Waktu Percobaan"
62 |
63 | #: .\axes\models.py:26
64 | msgid "Access lock out"
65 | msgstr "Akses terkunci"
66 |
67 | #: .\axes\models.py:34
68 | msgid "access failure"
69 | msgstr "kegagalan akses"
70 |
71 | #: .\axes\models.py:35
72 | msgid "access failures"
73 | msgstr "kegagalan akses"
74 |
75 | #: .\axes\models.py:39
76 | msgid "GET Data"
77 | msgstr "Data GET"
78 |
79 | #: .\axes\models.py:41
80 | msgid "POST Data"
81 | msgstr "Data POST"
82 |
83 | #: .\axes\models.py:43
84 | msgid "Failed Logins"
85 | msgstr "Login Gagal"
86 |
87 | #: .\axes\models.py:49
88 | msgid "access attempt"
89 | msgstr "upaya akses"
90 |
91 | #: .\axes\models.py:50
92 | msgid "access attempts"
93 | msgstr "upaya akses"
94 |
95 | #: .\axes\models.py:55
96 | msgid "Logout Time"
97 | msgstr "Waktu Logout"
98 |
99 | #: .\axes\models.py:61
100 | msgid "access log"
101 | msgstr "log akses"
102 |
103 | #: .\axes\models.py:62
104 | msgid "access logs"
105 | msgstr "log akses"
106 |
--------------------------------------------------------------------------------
/axes/locale/fr/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2023-11-06 05:21-0600\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: FULL NAME \n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Plural-Forms: nplurals=2; plural=(n > 1);\n"
20 |
21 | #: admin.py:27
22 | msgid "Form Data"
23 | msgstr "Données de formulaire"
24 |
25 | #: admin.py:28 admin.py:65 admin.py:100
26 | msgid "Meta Data"
27 | msgstr "Métadonnées"
28 |
29 | #: conf.py:108
30 | msgid "Account locked: too many login attempts. Please try again later."
31 | msgstr ""
32 | "Compte verrouillé: trop de tentatives de connexion. Veuillez réessayer plus "
33 | "tard."
34 |
35 | #: conf.py:116
36 | msgid ""
37 | "Account locked: too many login attempts. Contact an admin to unlock your "
38 | "account."
39 | msgstr ""
40 | "Compte verrouillé: trop de tentatives de connexion. Contactez un "
41 | "administrateur pour déverrouiller votre compte."
42 |
43 | #: models.py:6
44 | msgid "User Agent"
45 | msgstr "User Agent"
46 |
47 | #: models.py:8
48 | msgid "IP Address"
49 | msgstr "Adresse IP"
50 |
51 | #: models.py:10
52 | msgid "Username"
53 | msgstr "Nom d'utilisateur"
54 |
55 | #: models.py:12
56 | msgid "HTTP Accept"
57 | msgstr "HTTP Accept"
58 |
59 | #: models.py:14
60 | msgid "Path"
61 | msgstr "Chemin"
62 |
63 | #: models.py:16
64 | msgid "Attempt Time"
65 | msgstr "Date de la tentative"
66 |
67 | #: models.py:26
68 | msgid "Access lock out"
69 | msgstr "Verrouillage de l'accès"
70 |
71 | #: models.py:34
72 | msgid "access failure"
73 | msgstr "échec de connexion"
74 |
75 | #: models.py:35
76 | msgid "access failures"
77 | msgstr "échecs de connexion"
78 |
79 | #: models.py:39
80 | msgid "GET Data"
81 | msgstr "Données GET"
82 |
83 | #: models.py:41
84 | msgid "POST Data"
85 | msgstr "Données POST"
86 |
87 | #: models.py:43
88 | msgid "Failed Logins"
89 | msgstr "Nombre d'échecs"
90 |
91 | #: models.py:49
92 | msgid "access attempt"
93 | msgstr "tentative de connexion"
94 |
95 | #: models.py:50
96 | msgid "access attempts"
97 | msgstr "tentatives de connexion"
98 |
99 | #: models.py:55
100 | msgid "Logout Time"
101 | msgstr "Date de la déconnexion"
102 |
103 | #: models.py:61
104 | msgid "access log"
105 | msgstr "connexion"
106 |
107 | #: models.py:62
108 | msgid "access logs"
109 | msgstr "connexions"
110 |
--------------------------------------------------------------------------------
/axes/locale/fa/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # ترجمه فارسی برای django-axes
2 | # Copyright (C) 2025 jazzband
3 | # This file is distributed under the same license as the django-axes package.
4 | # AmirAli Bahramjerdi , 2025.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: django-axes\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2025-05-16 23:28+0330\n"
12 | "PO-Revision-Date: 2025-05-16 23:30+0330\n"
13 | "Last-Translator: AmirAli Bahramjerdi "
14 | "Language-Team: فارسی \n"
15 | "Language: fa\n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Plural-Forms: nplurals=2; plural=(n > 1);\n"
20 |
21 | #: admin.py:27
22 | msgid "Form Data"
23 | msgstr "دادههای فرم"
24 |
25 | #: admin.py:28 admin.py:65 admin.py:100
26 | msgid "Meta Data"
27 | msgstr "فراداده"
28 |
29 | #: conf.py:109
30 | msgid "Account locked: too many login attempts. Please try again later."
31 | msgstr "حساب کاربری قفل شد: تلاشهای زیادی برای ورود انجام شده است. لطفاً بعداً دوباره امتحان کنید."
32 |
33 | #: conf.py:117
34 | msgid ""
35 | "Account locked: too many login attempts. Contact an admin to unlock your "
36 | "account."
37 | msgstr "حساب کاربری قفل شد: تلاشهای زیادی برای ورود انجام شده است. برای باز کردن حساب با مدیر تماس بگیرید."
38 |
39 | #: models.py:6
40 | msgid "User Agent"
41 | msgstr "عامل کاربر (User Agent)"
42 |
43 | #: models.py:8
44 | msgid "IP Address"
45 | msgstr "آدرس IP"
46 |
47 | #: models.py:10
48 | msgid "Username"
49 | msgstr "نام کاربری"
50 |
51 | #: models.py:12
52 | msgid "HTTP Accept"
53 | msgstr "پذیرش HTTP"
54 |
55 | #: models.py:14
56 | msgid "Path"
57 | msgstr "مسیر"
58 |
59 | #: models.py:16
60 | msgid "Attempt Time"
61 | msgstr "زمان تلاش"
62 |
63 | #: models.py:26
64 | msgid "Access lock out"
65 | msgstr "قفل دسترسی"
66 |
67 | #: models.py:34
68 | msgid "access failure"
69 | msgstr "شکست در دسترسی"
70 |
71 | #: models.py:35
72 | msgid "access failures"
73 | msgstr "شکستهای دسترسی"
74 |
75 | #: models.py:39
76 | msgid "GET Data"
77 | msgstr "دادههای GET"
78 |
79 | #: models.py:41
80 | msgid "POST Data"
81 | msgstr "دادههای POST"
82 |
83 | #: models.py:43
84 | msgid "Failed Logins"
85 | msgstr "ورودهای ناموفق"
86 |
87 | #: models.py:49
88 | msgid "access attempt"
89 | msgstr "تلاش برای دسترسی"
90 |
91 | #: models.py:50
92 | msgid "access attempts"
93 | msgstr "تلاشهای دسترسی"
94 |
95 | #: models.py:55
96 | msgid "Logout Time"
97 | msgstr "زمان خروج"
98 |
99 | #: models.py:56
100 | msgid "Session key hash (sha256)"
101 | msgstr "هش کلید نشست (sha256)"
102 |
103 | #: models.py:62
104 | msgid "access log"
105 | msgstr "گزارش دسترسی"
106 |
107 | #: models.py:63
108 | msgid "access logs"
109 | msgstr "گزارشهای دسترسی"
110 |
--------------------------------------------------------------------------------
/axes/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 | from django.utils.translation import gettext_lazy as _
3 |
4 |
5 | class AccessBase(models.Model):
6 | user_agent = models.CharField(_("User Agent"), max_length=255, db_index=True)
7 |
8 | ip_address = models.GenericIPAddressField(_("IP Address"), null=True, db_index=True)
9 |
10 | username = models.CharField(_("Username"), max_length=255, null=True, db_index=True)
11 |
12 | http_accept = models.CharField(_("HTTP Accept"), max_length=1025)
13 |
14 | path_info = models.CharField(_("Path"), max_length=255)
15 |
16 | attempt_time = models.DateTimeField(_("Attempt Time"), auto_now_add=True)
17 |
18 | class Meta:
19 | app_label = "axes"
20 | abstract = True
21 | ordering = ["-attempt_time"]
22 |
23 |
24 | class AccessFailureLog(AccessBase):
25 | locked_out = models.BooleanField(
26 | _("Access lock out"), null=False, blank=True, default=False
27 | )
28 |
29 | def __str__(self):
30 | locked_out_str = " locked out" if self.locked_out else ""
31 | return f"Failed access: user {self.username}{locked_out_str} on {self.attempt_time} from {self.ip_address}"
32 |
33 | class Meta:
34 | verbose_name = _("access failure")
35 | verbose_name_plural = _("access failures")
36 |
37 |
38 | class AccessAttempt(AccessBase):
39 | get_data = models.TextField(_("GET Data"))
40 |
41 | post_data = models.TextField(_("POST Data"))
42 |
43 | failures_since_start = models.PositiveIntegerField(_("Failed Logins"))
44 |
45 | def __str__(self):
46 | return f"Attempted Access: {self.attempt_time}"
47 |
48 | class Meta:
49 | verbose_name = _("access attempt")
50 | verbose_name_plural = _("access attempts")
51 | unique_together = [["username", "ip_address", "user_agent"]]
52 |
53 |
54 | class AccessAttemptExpiration(models.Model):
55 | access_attempt = models.OneToOneField(
56 | AccessAttempt,
57 | primary_key=True,
58 | on_delete=models.CASCADE,
59 | related_name="expiration",
60 | verbose_name=_("Access Attempt"),
61 | )
62 | expires_at = models.DateTimeField(
63 | _("Expires At"),
64 | help_text=_("The time when access attempt expires and is no longer valid."),
65 | )
66 |
67 | class Meta:
68 | verbose_name = _("access attempt expiration")
69 | verbose_name_plural = _("access attempt expirations")
70 |
71 | class AccessLog(AccessBase):
72 | logout_time = models.DateTimeField(_("Logout Time"), null=True, blank=True)
73 | session_hash = models.CharField(_("Session key hash (sha256)"), default="", blank=True, max_length=64)
74 |
75 | def __str__(self):
76 | return f"Access Log for {self.username} @ {self.attempt_time}"
77 |
78 | class Meta:
79 | verbose_name = _("access log")
80 | verbose_name_plural = _("access logs")
81 |
--------------------------------------------------------------------------------
/axes/locale/ru/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2023-05-13 12:36+0500\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: FULL NAME \n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
20 |
21 | #: axes/admin.py:27
22 | msgid "Form Data"
23 | msgstr "Данные формы"
24 |
25 | #: axes/admin.py:28 axes/admin.py:65 axes/admin.py:100
26 | msgid "Meta Data"
27 | msgstr "Метаданные"
28 |
29 | #: axes/conf.py:99
30 | msgid "Account locked: too many login attempts. Please try again later."
31 | msgstr ""
32 | "Учетная запись заблокирована: слишком много попыток входа. Повторите попытку "
33 | "позже."
34 |
35 | #: axes/conf.py:107
36 | msgid ""
37 | "Account locked: too many login attempts. Contact an admin to unlock your "
38 | "account."
39 | msgstr ""
40 | "Учетная запись заблокирована: слишком много попыток входа. Свяжитесь с "
41 | "администратором, чтобы разблокировать учетную запись."
42 |
43 | #: axes/models.py:6
44 | msgid "User Agent"
45 | msgstr "User Agent"
46 |
47 | #: axes/models.py:8
48 | msgid "IP Address"
49 | msgstr "IP Адрес"
50 |
51 | #: axes/models.py:10
52 | msgid "Username"
53 | msgstr "Имя пользователя"
54 |
55 | #: axes/models.py:12
56 | msgid "HTTP Accept"
57 | msgstr "HTTP Accept"
58 |
59 | #: axes/models.py:14
60 | msgid "Path"
61 | msgstr "Путь"
62 |
63 | #: axes/models.py:16
64 | msgid "Attempt Time"
65 | msgstr "Время попытки входа"
66 |
67 | #: axes/models.py:26
68 | msgid "Access lock out"
69 | msgstr "Доступ запрещен"
70 |
71 | #: axes/models.py:34
72 | msgid "access failure"
73 | msgstr "Ошибка доступа"
74 |
75 | #: axes/models.py:35
76 | msgid "access failures"
77 | msgstr "Ошибки доступа"
78 |
79 | #: axes/models.py:39
80 | msgid "GET Data"
81 | msgstr "Данные GET-запроса"
82 |
83 | #: axes/models.py:41
84 | msgid "POST Data"
85 | msgstr "Данные POST-запроса"
86 |
87 | #: axes/models.py:43
88 | msgid "Failed Logins"
89 | msgstr "Ошибочные попытки"
90 |
91 | #: axes/models.py:49
92 | msgid "access attempt"
93 | msgstr "Запись о попытке доступа"
94 |
95 | #: axes/models.py:50
96 | msgid "access attempts"
97 | msgstr "Попытки доступа"
98 |
99 | #: axes/models.py:55
100 | msgid "Logout Time"
101 | msgstr "Время выхода"
102 |
103 | #: axes/models.py:61
104 | msgid "access log"
105 | msgstr "Запись о доступе"
106 |
107 | #: axes/models.py:62
108 | msgid "access logs"
109 | msgstr "Логи доступа"
110 |
--------------------------------------------------------------------------------
/axes/locale/de/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2022-05-27 11:46+0200\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: FULL NAME \n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
20 |
21 | #: .\axes\admin.py:27
22 | msgid "Form Data"
23 | msgstr "Form-Daten"
24 |
25 | #: .\axes\admin.py:28 .\axes\admin.py:65 .\axes\admin.py:100
26 | msgid "Meta Data"
27 | msgstr "Meta-Daten"
28 |
29 | #: .\axes\conf.py:97
30 | msgid "Account locked: too many login attempts. Please try again later."
31 | msgstr ""
32 | "Zugang gesperrt: zu viele fehlgeschlagene Anmeldeversuche. Bitte versuchen "
33 | "Sie es später erneut."
34 |
35 | #: .\axes\conf.py:105
36 | msgid ""
37 | "Account locked: too many login attempts. Contact an admin to unlock your "
38 | "account."
39 | msgstr ""
40 | "Zugang gesperrt: zu viele fehlgeschlagene Anmeldeversuche. Kontaktieren Sie "
41 | "einen Administrator, um Ihren Zugang zu entsperren."
42 |
43 | #: .\axes\models.py:6
44 | msgid "User Agent"
45 | msgstr "Browserkennung"
46 |
47 | #: .\axes\models.py:8
48 | msgid "IP Address"
49 | msgstr "IP-Adresse"
50 |
51 | #: .\axes\models.py:10
52 | msgid "Username"
53 | msgstr "Benutzername"
54 |
55 | #: .\axes\models.py:12
56 | msgid "HTTP Accept"
57 | msgstr "HTTP-Accept"
58 |
59 | #: .\axes\models.py:14
60 | msgid "Path"
61 | msgstr "Pfad"
62 |
63 | #: .\axes\models.py:16
64 | msgid "Attempt Time"
65 | msgstr "Zugriffszeitpunkt"
66 |
67 | #: .\axes\models.py:26
68 | #| msgid "access log"
69 | msgid "Access lock out"
70 | msgstr "Zugriff gesperrt"
71 |
72 | #: .\axes\models.py:34
73 | #| msgid "access log"
74 | msgid "access failure"
75 | msgstr "Fehlgeschlagener Zugriff"
76 |
77 | #: .\axes\models.py:35
78 | #| msgid "access logs"
79 | msgid "access failures"
80 | msgstr "Fehlgeschlagene Zugriffe"
81 |
82 | #: .\axes\models.py:39
83 | msgid "GET Data"
84 | msgstr "GET-Daten"
85 |
86 | #: .\axes\models.py:41
87 | msgid "POST Data"
88 | msgstr "POST-Daten"
89 |
90 | #: .\axes\models.py:43
91 | msgid "Failed Logins"
92 | msgstr "Fehlgeschlagene Anmeldeversuche"
93 |
94 | #: .\axes\models.py:49
95 | msgid "access attempt"
96 | msgstr "Zugriffsversuch"
97 |
98 | #: .\axes\models.py:50
99 | msgid "access attempts"
100 | msgstr "Zugriffsversuche"
101 |
102 | #: .\axes\models.py:55
103 | msgid "Logout Time"
104 | msgstr "Abmeldezeitpunkt"
105 |
106 | #: .\axes\models.py:61
107 | msgid "access log"
108 | msgstr "Zugriffslog"
109 |
110 | #: .\axes\models.py:62
111 | msgid "access logs"
112 | msgstr "Zugriffslogs"
113 |
--------------------------------------------------------------------------------
/axes/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | from django.db import migrations, models
2 |
3 |
4 | class Migration(migrations.Migration):
5 | dependencies = []
6 |
7 | operations = [
8 | migrations.CreateModel(
9 | name="AccessAttempt",
10 | fields=[
11 | (
12 | "id",
13 | models.AutoField(
14 | verbose_name="ID",
15 | serialize=False,
16 | auto_created=True,
17 | primary_key=True,
18 | ),
19 | ),
20 | ("user_agent", models.CharField(max_length=255)),
21 | (
22 | "ip_address",
23 | models.GenericIPAddressField(null=True, verbose_name="IP Address"),
24 | ),
25 | ("username", models.CharField(max_length=255, null=True)),
26 | ("trusted", models.BooleanField(default=False)),
27 | (
28 | "http_accept",
29 | models.CharField(max_length=1025, verbose_name="HTTP Accept"),
30 | ),
31 | ("path_info", models.CharField(max_length=255, verbose_name="Path")),
32 | ("attempt_time", models.DateTimeField(auto_now_add=True)),
33 | ("get_data", models.TextField(verbose_name="GET Data")),
34 | ("post_data", models.TextField(verbose_name="POST Data")),
35 | (
36 | "failures_since_start",
37 | models.PositiveIntegerField(verbose_name="Failed Logins"),
38 | ),
39 | ],
40 | options={"ordering": ["-attempt_time"], "abstract": False},
41 | ),
42 | migrations.CreateModel(
43 | name="AccessLog",
44 | fields=[
45 | (
46 | "id",
47 | models.AutoField(
48 | verbose_name="ID",
49 | serialize=False,
50 | auto_created=True,
51 | primary_key=True,
52 | ),
53 | ),
54 | ("user_agent", models.CharField(max_length=255)),
55 | (
56 | "ip_address",
57 | models.GenericIPAddressField(null=True, verbose_name="IP Address"),
58 | ),
59 | ("username", models.CharField(max_length=255, null=True)),
60 | ("trusted", models.BooleanField(default=False)),
61 | (
62 | "http_accept",
63 | models.CharField(max_length=1025, verbose_name="HTTP Accept"),
64 | ),
65 | ("path_info", models.CharField(max_length=255, verbose_name="Path")),
66 | ("attempt_time", models.DateTimeField(auto_now_add=True)),
67 | ("logout_time", models.DateTimeField(null=True, blank=True)),
68 | ],
69 | options={"ordering": ["-attempt_time"], "abstract": False},
70 | ),
71 | ]
72 |
--------------------------------------------------------------------------------
/docs/7_architecture.rst:
--------------------------------------------------------------------------------
1 | .. _architecture:
2 |
3 | Architecture
4 | ============
5 |
6 | Axes is based on the existing Django authentication backend
7 | architecture and framework for recognizing users and aims to be
8 | compatible with the stock design and implementation of Django
9 | while offering extensibility and configurability for using the
10 | Axes authentication monitoring and logging for users of the package
11 | as well as 3rd party package vendors such as Django REST Framework,
12 | Django Allauth, Python Social Auth and so forth.
13 |
14 | The development of custom 3rd party package support are active goals,
15 | but you should check the up-to-date documentation and implementation
16 | of Axes for current compatibility before using Axes with custom solutions
17 | and make sure that authentication monitoring is working correctly.
18 |
19 | This document describes the Django authentication flow
20 | and how Axes augments it to achieve authentication and login
21 | monitoring and lock users out on too many access attempts.
22 |
23 |
24 | Django Axes authentication flow
25 | -------------------------------
26 |
27 | Axes offers a few additions to the Django authentication flow
28 | that implement the login monitoring and lockouts through a swappable
29 | **handler** API and configuration flags that users and package vendors
30 | can use to customize Axes or their own projects as they best see fit.
31 |
32 | The following diagram visualizes the Django login flow
33 | and highlights the following extra steps that Axes adds to it with the
34 | **1. Authentication backend**, **2. Signal receivers**, and **3. Middleware**.
35 |
36 | .. image:: images/flow.png
37 | :alt: Django Axes augmented authentication flow
38 | with custom authentication backend,
39 | signal receivers, and middleware
40 |
41 | When a user tries to log in in Django, the login is usually performed
42 | by running a number of authentication backends that check user login
43 | information by calling the ``authenticate`` function, which either
44 | returns a Django compatible ``User`` object or a ``None``.
45 |
46 | If an authentication backend does not approve a user login,
47 | it can raise a ``PermissionDenied`` exception, which immediately
48 | skips the rest of the authentication backends, triggers the
49 | ``user_login_failed`` signal, and then returns a ``None``
50 | to the calling function, indicating that the login failed.
51 |
52 | Axes implements authentication blocking with the custom
53 | ``AxesBackend`` authentication backend which checks every request
54 | coming through the Django authentication flow and verifies they
55 | are not blocked, and allows the requests to go through if the check passes.
56 |
57 | If the authentication attempt matches a lockout rule, e.g. it is from a
58 | blacklisted IP or exceeds the maximum configured authentication attempts,
59 | it is blocked by raising the ``PermissionDenied`` exception in the backend.
60 |
61 | Axes monitors logins with the ``user_login_failed`` signal receiver
62 | and records authentication failures from both the ``AxesBackend`` and
63 | other authentication backends and tracks the failed attempts
64 | by tracking the attempt IP address, username, user agent, or all of them.
65 |
66 | If the lockout rules match, then Axes marks the request
67 | as locked by setting a special attribute into the request.
68 | The ``AxesMiddleware`` then processes the request, returning
69 | a lockout response to the user, if the flag has been set.
70 |
71 | Axes assumes that the login views either call the ``authenticate`` method
72 | to log in users or otherwise take care of notifying Axes of authentication
73 | attempts and failures the same way Django does via authentication signals.
74 |
75 | The login flows can be customized and the Axes
76 | authentication backend, middleware, and signal receivers
77 | can easily be swapped to alternative implementations.
78 |
--------------------------------------------------------------------------------
/axes/backends.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 | from django.conf import settings
3 | from django.contrib.auth.backends import ModelBackend
4 | from django.http import HttpRequest
5 |
6 | from axes.exceptions import (
7 | AxesBackendPermissionDenied,
8 | AxesBackendRequestParameterRequired,
9 | )
10 | from axes.handlers.proxy import AxesProxyHandler
11 | from axes.helpers import get_credentials, get_lockout_message, toggleable
12 |
13 |
14 | class AxesStandaloneBackend:
15 | """
16 | Authentication backend class that forbids login attempts for locked out users.
17 |
18 | Use this class as the first item of ``AUTHENTICATION_BACKENDS`` to
19 | prevent locked out users from being logged in by the Django authentication flow.
20 |
21 | .. note:: This backend does not log your user in. It monitors login attempts.
22 | It also does not run any permissions checks at all.
23 | Authentication is handled by the following backends that are configured in ``AUTHENTICATION_BACKENDS``.
24 | """
25 |
26 | @toggleable
27 | def authenticate(
28 | self,
29 | request: HttpRequest,
30 | username: Optional[str] = None,
31 | password: Optional[str] = None,
32 | **kwargs: dict,
33 | ):
34 | """
35 | Checks user lockout status and raises an exception if user is not allowed to log in.
36 |
37 | This method interrupts the login flow and inserts error message directly to the
38 | ``response_context`` attribute that is supplied as a keyword argument.
39 |
40 | :keyword response_context: kwarg that will be have its ``error`` attribute updated with context.
41 | :raises AxesBackendRequestParameterRequired: if request parameter is not passed.
42 | :raises AxesBackendPermissionDenied: if user is already locked out.
43 | """
44 |
45 | if request is None:
46 | raise AxesBackendRequestParameterRequired(
47 | "AxesBackend requires a request as an argument to authenticate"
48 | )
49 |
50 | credentials = get_credentials(username=username, password=password, **kwargs)
51 |
52 | if AxesProxyHandler.is_allowed(request, credentials):
53 | return
54 |
55 | # Locked out, don't try to authenticate, just update response_context and return.
56 | # Its a bit weird to pass a context and expect a response value but its nice to get a "why" back.
57 |
58 | error_msg = get_lockout_message()
59 | response_context = kwargs.get("response_context", {})
60 | response_context["error"] = error_msg
61 |
62 | # This flag can be used later to check if it was Axes that denied the login attempt.
63 | if not settings.AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT:
64 | request.axes_locked_out = True
65 |
66 | # Raise an error that stops the authentication flows at django.contrib.auth.authenticate.
67 | # This error stops bubbling up at the authenticate call which catches backend PermissionDenied errors.
68 | # After this error is caught by authenticate it emits a signal indicating user login failed,
69 | # which is processed by axes.signals.log_user_login_failed which logs and flags the failed request.
70 | # The axes.middleware.AxesMiddleware further processes the flagged request into a readable response.
71 |
72 | raise AxesBackendPermissionDenied(
73 | "AxesBackend detected that the given user is locked out"
74 | )
75 |
76 |
77 | class AxesBackend(AxesStandaloneBackend, ModelBackend):
78 | """
79 | Axes authentication backend that also inherits from ModelBackend,
80 | and thus also performs other functions of ModelBackend such as permissions checks.
81 |
82 | Use this class as the first item of ``AUTHENTICATION_BACKENDS`` to
83 | prevent locked out users from being logged in by the Django authentication flow.
84 |
85 | .. note:: This backend does not log your user in. It monitors login attempts.
86 | Authentication is handled by the following backends that are configured in ``AUTHENTICATION_BACKENDS``.
87 | """
88 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | """
2 | Sphinx documentation generator configuration.
3 |
4 | More information on the configuration options is available at:
5 |
6 | https://www.sphinx-doc.org/en/master/usage/configuration.html
7 | """
8 |
9 | # import sphinx_rtd_theme
10 | from pkg_resources import get_distribution
11 |
12 | import django
13 | from django.conf import settings
14 |
15 | settings.configure(INSTALLED_APPS=["django", "django.contrib.auth", "axes"], DEBUG=True)
16 | django.setup()
17 |
18 |
19 | # -- Extra custom configuration ------------------------------------------
20 |
21 | title = "django-axes documentation"
22 | description = ("Keep track of failed login attempts in Django-powered sites.",)
23 |
24 | # -- General configuration ------------------------------------------------
25 |
26 | # Add any Sphinx extension module names here, as strings.
27 | # They can be extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
28 | extensions = ["sphinx_rtd_theme","sphinx.ext.autodoc"]
29 |
30 | # Add any paths that contain templates here, relative to this directory.
31 | templates_path = ["_templates"]
32 |
33 | # The suffix(es) of source filenames.
34 | # You can specify multiple suffix as a list of string: source_suffix = ['.rst', '.md']
35 | source_suffix = ".rst"
36 |
37 | # The master toctree document.
38 | master_doc = "index"
39 |
40 | # General information about the project.
41 | project = "django-axes"
42 | copyright = "2016, Jazzband"
43 | author = "Jazzband"
44 |
45 | # The full version, including alpha/beta/rc tags.
46 | release = get_distribution("django-axes").version
47 |
48 | # The short X.Y version.
49 | version = ".".join(release.split(".")[:2])
50 |
51 | # The language for content autogenerated by Sphinx. Refer to documentation
52 | # for a list of supported languages.
53 | #
54 | # This is also used if you do content translation via gettext catalogs.
55 | # Usually you set "language" from the command line for these cases.
56 | language = None
57 |
58 | # List of patterns, relative to source directory, that match files and
59 | # directories to ignore when looking for source files.
60 | exclude_patterns = ["_build"]
61 |
62 | # The name of the Pygments (syntax highlighting) style to use.
63 | pygments_style = "sphinx"
64 |
65 | # If true, `todo` and `todoList` produce output, else they produce nothing.
66 | todo_include_todos = False
67 |
68 | # -- Options for HTML output ----------------------------------------------
69 |
70 | # The theme to use for HTML and HTML Help pages. See the documentation for
71 | # a list of builtin themes.
72 | html_theme = "sphinx_rtd_theme"
73 |
74 | html_style = "css/custom_theme.css"
75 |
76 | # Add any paths that contain custom themes here, relative to this directory.
77 | # html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
78 |
79 | # Add any paths that contain custom static files (such as style sheets) here,
80 | # relative to this directory. They are copied after the builtin static files,
81 | # so a file named "default.css" will overwrite the builtin "default.css".
82 | html_static_path = ["_static"]
83 |
84 | # Custom sidebar templates, maps document names to template names.
85 | html_sidebars = {
86 | "**": ["globaltoc.html", "relations.html", "sourcelink.html", "searchbox.html"]
87 | }
88 |
89 | # Output file base name for HTML help builder.
90 | htmlhelp_basename = "DjangoAxesdoc"
91 |
92 | # -- Options for LaTeX output ---------------------------------------------
93 |
94 | latex_elements = {
95 | "papersize": "a4paper",
96 | "pointsize": "12pt",
97 | "preamble": "",
98 | "figure_align": "htbp",
99 | }
100 |
101 | # Grouping the document tree into LaTeX files. List of tuples
102 | # (source start file, target name, title, author, documentclass [howto, manual, or own class]).
103 | latex_documents = [(master_doc, "DjangoAxes.tex", title, author, "manual")]
104 |
105 | # -- Options for manual page output ---------------------------------------
106 |
107 | # One entry per manual page. List of tuples
108 | # (source start file, name, description, authors, manual section).
109 | man_pages = [(master_doc, "djangoaxes", description, [author], 1)]
110 |
111 | # -- Options for Texinfo output -------------------------------------------
112 |
113 | # Grouping the document tree into Texinfo files. List of tuples
114 | # (source start file, target name, title, author, dir menu entry, description, category)
115 | texinfo_documents = [
116 | (
117 | master_doc,
118 | "DjangoAxes",
119 | title,
120 | author,
121 | "DjangoAxes",
122 | description,
123 | "Miscellaneous",
124 | )
125 | ]
126 |
--------------------------------------------------------------------------------
/tests/test_management.py:
--------------------------------------------------------------------------------
1 | from io import StringIO
2 | from unittest.mock import patch, Mock
3 |
4 | from django.core.management import call_command
5 | from django.utils import timezone
6 |
7 | from axes.models import AccessAttempt, AccessLog
8 | from tests.base import AxesTestCase
9 |
10 |
11 | class ResetAccessLogsManagementCommandTestCase(AxesTestCase):
12 | def setUp(self):
13 | self.msg_not_found = "No logs found.\n"
14 | self.msg_num_found = "{} logs removed.\n"
15 |
16 | days_3 = timezone.now() - timezone.timedelta(days=3)
17 | with patch("django.utils.timezone.now", Mock(return_value=days_3)):
18 | AccessLog.objects.create()
19 |
20 | days_13 = timezone.now() - timezone.timedelta(days=9)
21 | with patch("django.utils.timezone.now", Mock(return_value=days_13)):
22 | AccessLog.objects.create()
23 |
24 | days_30 = timezone.now() - timezone.timedelta(days=27)
25 | with patch("django.utils.timezone.now", Mock(return_value=days_30)):
26 | AccessLog.objects.create()
27 |
28 | def test_axes_delete_access_logs_default(self):
29 | out = StringIO()
30 | call_command("axes_reset_logs", stdout=out)
31 | self.assertEqual(self.msg_not_found, out.getvalue())
32 |
33 | def test_axes_delete_access_logs_older_than_2_days(self):
34 | out = StringIO()
35 | call_command("axes_reset_logs", age=2, stdout=out)
36 | self.assertEqual(self.msg_num_found.format(3), out.getvalue())
37 |
38 | def test_axes_delete_access_logs_older_than_4_days(self):
39 | out = StringIO()
40 | call_command("axes_reset_logs", age=4, stdout=out)
41 | self.assertEqual(self.msg_num_found.format(2), out.getvalue())
42 |
43 | def test_axes_delete_access_logs_older_than_16_days(self):
44 | out = StringIO()
45 | call_command("axes_reset_logs", age=16, stdout=out)
46 | self.assertEqual(self.msg_num_found.format(1), out.getvalue())
47 |
48 |
49 | class ManagementCommandTestCase(AxesTestCase):
50 | def setUp(self):
51 | AccessAttempt.objects.create(
52 | username="jane.doe", ip_address="10.0.0.1", failures_since_start="4"
53 | )
54 |
55 | AccessAttempt.objects.create(
56 | username="john.doe", ip_address="10.0.0.2", failures_since_start="15"
57 | )
58 |
59 | AccessAttempt.objects.create(
60 | username="richard.doe", ip_address="10.0.0.4", failures_since_start="12"
61 | )
62 |
63 | def test_axes_list_attempts(self):
64 | out = StringIO()
65 | call_command("axes_list_attempts", stdout=out)
66 |
67 | expected = "10.0.0.1\tjane.doe\t4\n10.0.0.2\tjohn.doe\t15\n10.0.0.4\trichard.doe\t12\n"
68 | self.assertEqual(expected, out.getvalue())
69 |
70 | def test_axes_reset(self):
71 | out = StringIO()
72 | call_command("axes_reset", stdout=out)
73 |
74 | expected = "3 attempts removed.\n"
75 | self.assertEqual(expected, out.getvalue())
76 |
77 | def test_axes_reset_not_found(self):
78 | out = StringIO()
79 | call_command("axes_reset", stdout=out)
80 |
81 | out = StringIO()
82 | call_command("axes_reset", stdout=out)
83 |
84 | expected = "No attempts found.\n"
85 | self.assertEqual(expected, out.getvalue())
86 |
87 | def test_axes_reset_ip(self):
88 | out = StringIO()
89 | call_command("axes_reset_ip", "10.0.0.1", stdout=out)
90 |
91 | expected = "1 attempts removed.\n"
92 | self.assertEqual(expected, out.getvalue())
93 |
94 | def test_axes_reset_ip_username(self):
95 | out = StringIO()
96 | call_command("axes_reset_ip_username", "10.0.0.4", "richard.doe", stdout=out)
97 |
98 | expected = "1 attempts removed.\n"
99 | self.assertEqual(expected, out.getvalue())
100 |
101 | def test_axes_reset_ip_not_found(self):
102 | out = StringIO()
103 | call_command("axes_reset_ip", "10.0.0.3", stdout=out)
104 |
105 | expected = "No attempts found.\n"
106 | self.assertEqual(expected, out.getvalue())
107 |
108 | def test_axes_reset_username(self):
109 | out = StringIO()
110 | call_command("axes_reset_username", "john.doe", stdout=out)
111 |
112 | expected = "1 attempts removed.\n"
113 | self.assertEqual(expected, out.getvalue())
114 |
115 | def test_axes_reset_username_not_found(self):
116 | out = StringIO()
117 | call_command("axes_reset_username", "ivan.renko", stdout=out)
118 |
119 | expected = "No attempts found.\n"
120 | self.assertEqual(expected, out.getvalue())
121 |
--------------------------------------------------------------------------------
/tests/test_checks.py:
--------------------------------------------------------------------------------
1 | from django.core.checks import run_checks, Warning # pylint: disable=redefined-builtin
2 | from django.test import override_settings, modify_settings
3 |
4 | from axes.backends import AxesStandaloneBackend
5 | from axes.checks import Messages, Hints, Codes
6 | from tests.base import AxesTestCase
7 |
8 |
9 | class CacheCheckTestCase(AxesTestCase):
10 | @override_settings(
11 | AXES_HANDLER="axes.handlers.cache.AxesCacheHandler",
12 | CACHES={
13 | "default": {
14 | "BACKEND": "django.core.cache.backends.db.DatabaseCache",
15 | "LOCATION": "axes_cache",
16 | }
17 | },
18 | )
19 | def test_cache_check(self):
20 | warnings = run_checks()
21 | self.assertEqual(warnings, [])
22 |
23 | @override_settings(
24 | AXES_HANDLER="axes.handlers.cache.AxesCacheHandler",
25 | CACHES={
26 | "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}
27 | },
28 | )
29 | def test_cache_check_warnings(self):
30 | warnings = run_checks()
31 | warning = Warning(
32 | msg=Messages.CACHE_INVALID, hint=Hints.CACHE_INVALID, id=Codes.CACHE_INVALID
33 | )
34 |
35 | self.assertEqual(warnings, [warning])
36 |
37 | @override_settings(
38 | AXES_HANDLER="axes.handlers.database.AxesDatabaseHandler",
39 | CACHES={
40 | "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}
41 | },
42 | )
43 | def test_cache_check_does_not_produce_check_warnings_with_database_handler(self):
44 | warnings = run_checks()
45 | self.assertEqual(warnings, [])
46 |
47 |
48 | class MiddlewareCheckTestCase(AxesTestCase):
49 | @modify_settings(MIDDLEWARE={"remove": ["axes.middleware.AxesMiddleware"]})
50 | def test_cache_check_warnings(self):
51 | warnings = run_checks()
52 | warning = Warning(
53 | msg=Messages.MIDDLEWARE_INVALID,
54 | hint=Hints.MIDDLEWARE_INVALID,
55 | id=Codes.MIDDLEWARE_INVALID,
56 | )
57 |
58 | self.assertEqual(warnings, [warning])
59 |
60 |
61 | class AxesSpecializedBackend(AxesStandaloneBackend):
62 | pass
63 |
64 |
65 | class BackendCheckTestCase(AxesTestCase):
66 | @modify_settings(
67 | AUTHENTICATION_BACKENDS={"remove": ["axes.backends.AxesStandaloneBackend"]}
68 | )
69 | def test_backend_missing(self):
70 | warnings = run_checks()
71 | warning = Warning(
72 | msg=Messages.BACKEND_INVALID,
73 | hint=Hints.BACKEND_INVALID,
74 | id=Codes.BACKEND_INVALID,
75 | )
76 |
77 | self.assertEqual(warnings, [warning])
78 |
79 | @override_settings(
80 | AUTHENTICATION_BACKENDS=["tests.test_checks.AxesSpecializedBackend"]
81 | )
82 | def test_specialized_backend(self):
83 | warnings = run_checks()
84 | self.assertEqual(warnings, [])
85 |
86 | @override_settings(
87 | AUTHENTICATION_BACKENDS=["tests.test_checks.AxesNotDefinedBackend"]
88 | )
89 | def test_import_error(self):
90 | with self.assertRaises(ImportError):
91 | run_checks()
92 |
93 | @override_settings(AUTHENTICATION_BACKENDS=["module.not_defined"])
94 | def test_module_not_found_error(self):
95 | with self.assertRaises(ModuleNotFoundError):
96 | run_checks()
97 |
98 |
99 | class DeprecatedSettingsTestCase(AxesTestCase):
100 | def setUp(self):
101 | self.disable_success_access_log_warning = Warning(
102 | msg=Messages.SETTING_DEPRECATED.format(
103 | deprecated_setting="AXES_DISABLE_SUCCESS_ACCESS_LOG"
104 | ),
105 | hint=Hints.SETTING_DEPRECATED,
106 | id=Codes.SETTING_DEPRECATED,
107 | )
108 |
109 | @override_settings(AXES_DISABLE_SUCCESS_ACCESS_LOG=True)
110 | def test_deprecated_success_access_log_flag(self):
111 | warnings = run_checks()
112 | self.assertEqual(warnings, [self.disable_success_access_log_warning])
113 |
114 |
115 | class ConfCheckTestCase(AxesTestCase):
116 | @override_settings(AXES_USERNAME_CALLABLE="module.not_defined")
117 | def test_invalid_import_path(self):
118 | warnings = run_checks()
119 | warning = Warning(
120 | msg=Messages.CALLABLE_INVALID.format(
121 | callable_setting="AXES_USERNAME_CALLABLE"
122 | ),
123 | hint=Hints.CALLABLE_INVALID,
124 | id=Codes.CALLABLE_INVALID,
125 | )
126 | self.assertEqual(warnings, [warning])
127 |
128 | @override_settings(AXES_COOLOFF_TIME=lambda: 1)
129 | def test_valid_callable(self):
130 | warnings = run_checks()
131 | self.assertEqual(warnings, [])
132 |
--------------------------------------------------------------------------------
/tests/test_logging.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import patch
2 |
3 | from django.test import override_settings
4 |
5 | from axes import __version__
6 | from axes.apps import AppConfig
7 | from axes.models import AccessAttempt, AccessLog
8 | from tests.base import AxesTestCase
9 |
10 | _BEGIN = "AXES: BEGIN version %s, %s"
11 | _VERSION = __version__
12 |
13 |
14 | @patch("axes.apps.AppConfig.initialized", False)
15 | @patch("axes.apps.log")
16 | class AppsTestCase(AxesTestCase):
17 | def test_axes_config_log_re_entrant(self, log):
18 | """
19 | Test that initialize call count does not increase on repeat calls.
20 | """
21 |
22 | AppConfig.initialize()
23 | calls = log.info.call_count
24 |
25 | AppConfig.initialize()
26 | self.assertTrue(
27 | calls == log.info.call_count and calls > 0,
28 | "AxesConfig.initialize needs to be re-entrant",
29 | )
30 |
31 | @override_settings(AXES_VERBOSE=False)
32 | def test_axes_config_log_not_verbose(self, log):
33 | AppConfig.initialize()
34 | self.assertFalse(log.info.called)
35 |
36 | @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
37 | def test_axes_config_log_user_only(self, log):
38 | AppConfig.initialize()
39 | log.info.assert_called_with(_BEGIN, _VERSION, "blocking by username")
40 |
41 | def test_axes_config_log_ip_only(self, log):
42 | AppConfig.initialize()
43 | log.info.assert_called_with(_BEGIN, _VERSION, "blocking by ip_address")
44 |
45 | @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
46 | def test_axes_config_log_user_ip(self, log):
47 | AppConfig.initialize()
48 | log.info.assert_called_with(
49 | _BEGIN, _VERSION, "blocking by combination of username and ip_address"
50 | )
51 |
52 | @override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
53 | def test_axes_config_log_user_or_ip(self, log):
54 | AppConfig.initialize()
55 | log.info.assert_called_with(_BEGIN, _VERSION, "blocking by username or ip_address")
56 |
57 |
58 | class AccessLogTestCase(AxesTestCase):
59 | def test_access_log_on_logout(self):
60 | """
61 | Test a valid logout and make sure the logout_time is updated only for that.
62 | """
63 |
64 | self.login(is_valid_username=True, is_valid_password=True)
65 | latest_log = AccessLog.objects.latest("id")
66 | self.assertIsNone(latest_log.logout_time)
67 | other_log = self.create_log(session_hash='not-the-session')
68 | self.assertIsNone(other_log.logout_time)
69 |
70 | response = self.logout()
71 | self.assertContains(response, "Logged out")
72 | other_log.refresh_from_db()
73 | self.assertIsNone(other_log.logout_time)
74 | latest_log.refresh_from_db()
75 | self.assertIsNotNone(latest_log.logout_time)
76 |
77 | @override_settings(DATA_UPLOAD_MAX_NUMBER_FIELDS=1500)
78 | def test_log_data_truncated(self):
79 | """
80 | Test that get_query_str properly truncates data to the max_length (default 1024).
81 | """
82 |
83 | # An impossibly large post dict
84 | extra_data = {"too-large-field": "x" * 2 ** 16}
85 | self.login(**extra_data)
86 | self.assertEqual(len(AccessAttempt.objects.latest("id").post_data), 1024)
87 |
88 | @override_settings(AXES_DISABLE_ACCESS_LOG=True)
89 | def test_valid_logout_without_success_log(self):
90 | AccessLog.objects.all().delete()
91 |
92 | response = self.login(is_valid_username=True, is_valid_password=True)
93 | response = self.logout()
94 |
95 | self.assertEqual(AccessLog.objects.all().count(), 0)
96 | self.assertContains(response, "Logged out", html=True)
97 |
98 | @override_settings(AXES_DISABLE_ACCESS_LOG=True)
99 | def test_valid_login_without_success_log(self):
100 | """
101 | Test that a valid login does not generate an AccessLog when DISABLE_SUCCESS_ACCESS_LOG is True.
102 | """
103 |
104 | AccessLog.objects.all().delete()
105 |
106 | response = self.login(is_valid_username=True, is_valid_password=True)
107 |
108 | self.assertEqual(response.status_code, 302)
109 | self.assertEqual(AccessLog.objects.all().count(), 0)
110 |
111 | @override_settings(AXES_DISABLE_ACCESS_LOG=True)
112 | def test_valid_logout_without_log(self):
113 | AccessLog.objects.all().delete()
114 |
115 | response = self.login(is_valid_username=True, is_valid_password=True)
116 | response = self.logout()
117 |
118 | self.assertEqual(AccessLog.objects.count(), 0)
119 | self.assertContains(response, "Logged out", html=True)
120 |
121 | @override_settings(AXES_DISABLE_ACCESS_LOG=True)
122 | def test_non_valid_login_without_log(self):
123 | """
124 | Test that a non-valid login does generate an AccessLog when DISABLE_ACCESS_LOG is True.
125 | """
126 | AccessLog.objects.all().delete()
127 |
128 | response = self.login(is_valid_username=True, is_valid_password=False)
129 | self.assertEqual(response.status_code, 200)
130 |
131 | self.assertEqual(AccessLog.objects.all().count(), 0)
132 |
--------------------------------------------------------------------------------
/axes/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.http import HttpRequest
3 | from django.utils.translation import gettext_lazy as _
4 |
5 | from axes.conf import settings
6 | from axes.models import AccessAttempt, AccessLog, AccessFailureLog
7 | from axes.handlers.database import AxesDatabaseHandler
8 |
9 |
10 | class IsLockedOutFilter(admin.SimpleListFilter):
11 | title = _("Locked Out")
12 | parameter_name = "locked_out"
13 |
14 | def lookups(self, request, model_admin):
15 | return (
16 | ("yes", _("Yes")),
17 | ("no", _("No")),
18 | )
19 |
20 | def queryset(self, request, queryset):
21 | if self.value() == "yes":
22 | return queryset.filter(failures_since_start__gte=settings.AXES_FAILURE_LIMIT)
23 | elif self.value() == "no":
24 | return queryset.filter(failures_since_start__lt=settings.AXES_FAILURE_LIMIT)
25 | return queryset
26 |
27 |
28 | class AccessAttemptAdmin(admin.ModelAdmin):
29 | list_display = [
30 | "attempt_time",
31 | "ip_address",
32 | "user_agent",
33 | "username",
34 | "path_info",
35 | "failures_since_start",
36 | ]
37 |
38 | if settings.AXES_USE_ATTEMPT_EXPIRATION:
39 | list_display.append('expiration')
40 |
41 | list_filter = ["attempt_time", "path_info"]
42 |
43 | if isinstance(settings.AXES_FAILURE_LIMIT, int) and settings.AXES_FAILURE_LIMIT > 0:
44 | # This will only add the status field if AXES_FAILURE_LIMIT is set to a positive integer
45 | # Because callable failure limit requires scope of request object
46 | list_display.append("status")
47 | list_filter.append(IsLockedOutFilter)
48 |
49 | search_fields = ["ip_address", "username", "user_agent", "path_info"]
50 |
51 | date_hierarchy = "attempt_time"
52 |
53 | fieldsets = (
54 | (None, {"fields": ("username", "path_info", "failures_since_start", "expiration")}),
55 | (_("Form Data"), {"fields": ("get_data", "post_data")}),
56 | (_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
57 | )
58 |
59 | readonly_fields = [
60 | "user_agent",
61 | "ip_address",
62 | "username",
63 | "http_accept",
64 | "path_info",
65 | "attempt_time",
66 | "get_data",
67 | "post_data",
68 | "failures_since_start",
69 | "expiration",
70 | ]
71 |
72 | actions = ['cleanup_expired_attempts']
73 |
74 | @admin.action(description=_('Clean up expired attempts'))
75 | def cleanup_expired_attempts(self, request, queryset):
76 | count = self.handler.clean_expired_user_attempts(request=request)
77 | self.message_user(request, _(f"Cleaned up {count} expired access attempts."))
78 |
79 | def __init__(self, *args, **kwargs):
80 | super().__init__(*args, **kwargs)
81 | self.handler = AxesDatabaseHandler()
82 |
83 | def has_add_permission(self, request: HttpRequest) -> bool:
84 | return False
85 |
86 | def expiration(self, obj: AccessAttempt):
87 | return obj.expiration.expires_at if hasattr(obj, "expiration") else _("Not set")
88 |
89 | def status(self, obj: AccessAttempt):
90 | return f"{settings.AXES_FAILURE_LIMIT - obj.failures_since_start} "+_("Attempt Remaining") if \
91 | obj.failures_since_start < settings.AXES_FAILURE_LIMIT else _("Locked Out")
92 |
93 | class AccessLogAdmin(admin.ModelAdmin):
94 | list_display = (
95 | "attempt_time",
96 | "logout_time",
97 | "ip_address",
98 | "username",
99 | "user_agent",
100 | "path_info",
101 | )
102 |
103 | list_filter = ["attempt_time", "logout_time", "path_info"]
104 |
105 | search_fields = ["ip_address", "user_agent", "username", "path_info"]
106 |
107 | date_hierarchy = "attempt_time"
108 |
109 | fieldsets = (
110 | (None, {"fields": ("username", "path_info")}),
111 | (_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
112 | )
113 |
114 | readonly_fields = [
115 | "user_agent",
116 | "ip_address",
117 | "username",
118 | "http_accept",
119 | "path_info",
120 | "attempt_time",
121 | "logout_time",
122 | ]
123 |
124 | def has_add_permission(self, request: HttpRequest) -> bool:
125 | return False
126 |
127 |
128 | class AccessFailureLogAdmin(admin.ModelAdmin):
129 | list_display = (
130 | "attempt_time",
131 | "ip_address",
132 | "username",
133 | "user_agent",
134 | "path_info",
135 | "locked_out",
136 | )
137 |
138 | list_filter = ["attempt_time", "locked_out", "path_info"]
139 |
140 | search_fields = ["ip_address", "user_agent", "username", "path_info"]
141 |
142 | date_hierarchy = "attempt_time"
143 |
144 | fieldsets = (
145 | (None, {"fields": ("username", "path_info")}),
146 | (_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}),
147 | )
148 |
149 | readonly_fields = [
150 | "user_agent",
151 | "ip_address",
152 | "username",
153 | "http_accept",
154 | "path_info",
155 | "attempt_time",
156 | "locked_out",
157 | ]
158 |
159 | def has_add_permission(self, request: HttpRequest) -> bool:
160 | return False
161 |
162 |
163 | if settings.AXES_ENABLE_ADMIN:
164 | admin.site.register(AccessAttempt, AccessAttemptAdmin)
165 | admin.site.register(AccessLog, AccessLogAdmin)
166 | admin.site.register(AccessFailureLog, AccessFailureLogAdmin)
167 |
--------------------------------------------------------------------------------
/axes/handlers/proxy.py:
--------------------------------------------------------------------------------
1 | # pylint: disable=arguments-differ
2 | # pylint generates false negatives from proxy class method overrides
3 |
4 | from logging import getLogger
5 | from typing import Optional
6 |
7 | from django.utils.module_loading import import_string
8 | from django.utils.timezone import now
9 |
10 | from axes.conf import settings
11 | from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler, AxesHandler
12 | from axes.helpers import (
13 | get_client_ip_address,
14 | get_client_user_agent,
15 | get_client_path_info,
16 | get_client_http_accept,
17 | toggleable,
18 | )
19 |
20 | log = getLogger(__name__)
21 |
22 |
23 | class AxesProxyHandler(AbstractAxesHandler, AxesBaseHandler):
24 | """
25 | Proxy interface for configurable Axes signal handler class.
26 |
27 | If you wish to implement a custom version of this handler,
28 | you can override the settings.AXES_HANDLER configuration string
29 | with a class that implements a compatible interface and methods.
30 |
31 | Defaults to using axes.handlers.proxy.AxesProxyHandler if not overridden.
32 | Refer to axes.handlers.proxy.AxesProxyHandler for default implementation.
33 | """
34 |
35 | implementation = None # type: AxesHandler
36 |
37 | @classmethod
38 | def get_implementation(cls, force: bool = False) -> AxesHandler:
39 | """
40 | Fetch and initialize configured handler implementation and memoize it to avoid reinitialization.
41 |
42 | This method is re-entrant and can be called multiple times from e.g. Django application loader.
43 | """
44 |
45 | if force or not cls.implementation:
46 | cls.implementation = import_string(settings.AXES_HANDLER)()
47 | return cls.implementation
48 |
49 | @classmethod
50 | def reset_attempts(
51 | cls,
52 | *,
53 | ip_address: Optional[str] = None,
54 | username: Optional[str] = None,
55 | ip_or_username: bool = False,
56 | ) -> int:
57 | return cls.get_implementation().reset_attempts(
58 | ip_address=ip_address, username=username, ip_or_username=ip_or_username
59 | )
60 |
61 | @classmethod
62 | def reset_logs(cls, *, age_days: Optional[int] = None) -> int:
63 | return cls.get_implementation().reset_logs(age_days=age_days)
64 |
65 | @classmethod
66 | def reset_failure_logs(cls, *, age_days: Optional[int] = None) -> int:
67 | return cls.get_implementation().reset_failure_logs(age_days=age_days)
68 |
69 | @classmethod
70 | def remove_out_of_limit_failure_logs(
71 | cls, *, username: str, limit: Optional[int] = None
72 | ) -> int:
73 | return cls.get_implementation().remove_out_of_limit_failure_logs(
74 | username=username
75 | )
76 |
77 | @staticmethod
78 | def update_request(request):
79 | """
80 | Update request attributes before passing them into the selected handler class.
81 | """
82 |
83 | if request is None:
84 | log.error(
85 | "AXES: AxesProxyHandler.update_request can not set request attributes to a None request"
86 | )
87 | return
88 | if not hasattr(request, "axes_updated"):
89 | if not hasattr(request, "axes_locked_out"):
90 | request.axes_locked_out = False
91 | request.axes_attempt_time = now()
92 | request.axes_ip_address = get_client_ip_address(request)
93 | request.axes_user_agent = get_client_user_agent(request)
94 | request.axes_path_info = get_client_path_info(request)
95 | request.axes_http_accept = get_client_http_accept(request)
96 | request.axes_failures_since_start = None
97 | request.axes_updated = True
98 | request.axes_credentials = None
99 |
100 | @classmethod
101 | def is_locked(cls, request, credentials: Optional[dict] = None) -> bool:
102 | cls.update_request(request)
103 | return cls.get_implementation().is_locked(request, credentials)
104 |
105 | @classmethod
106 | def is_allowed(cls, request, credentials: Optional[dict] = None) -> bool:
107 | cls.update_request(request)
108 | return cls.get_implementation().is_allowed(request, credentials)
109 |
110 | @classmethod
111 | def get_failures(cls, request, credentials: Optional[dict] = None) -> int:
112 | cls.update_request(request)
113 | return cls.get_implementation().get_failures(request, credentials)
114 |
115 | @classmethod
116 | @toggleable
117 | def user_login_failed(cls, sender, credentials: dict, request=None, **kwargs):
118 | cls.update_request(request)
119 | return cls.get_implementation().user_login_failed(
120 | sender, credentials, request, **kwargs
121 | )
122 |
123 | @classmethod
124 | @toggleable
125 | def user_logged_in(cls, sender, request, user, **kwargs):
126 | cls.update_request(request)
127 | return cls.get_implementation().user_logged_in(sender, request, user, **kwargs)
128 |
129 | @classmethod
130 | @toggleable
131 | def user_logged_out(cls, sender, request, user, **kwargs):
132 | cls.update_request(request)
133 | return cls.get_implementation().user_logged_out(sender, request, user, **kwargs)
134 |
135 | @classmethod
136 | @toggleable
137 | def post_save_access_attempt(cls, instance, **kwargs):
138 | return cls.get_implementation().post_save_access_attempt(instance, **kwargs)
139 |
140 | @classmethod
141 | @toggleable
142 | def post_delete_access_attempt(cls, instance, **kwargs):
143 | return cls.get_implementation().post_delete_access_attempt(instance, **kwargs)
144 |
--------------------------------------------------------------------------------
/docs/3_usage.rst:
--------------------------------------------------------------------------------
1 | .. _usage:
2 |
3 | Usage
4 | =====
5 |
6 | Once Axes is installed and configured, you can login and logout
7 | of your application via the ``django.contrib.auth`` views.
8 | The attempts will be logged and visible in the Access Attempts section in admin.
9 |
10 | Axes monitors the views by using the Django login and logout signals and
11 | locks out user attempts with a custom authentication backend that checks
12 | if requests are allowed to authenticate per the configured rules.
13 |
14 | By default, Axes will lock out repeated access attempts from the same IP address
15 | by monitoring login failures and storing them into the default database.
16 |
17 |
18 | Authenticating users
19 | --------------------
20 |
21 | Axes needs a ``request`` attribute to be supplied to the stock Django ``authenticate``
22 | method in the ``django.contrib.auth`` module in order to function correctly.
23 |
24 | If you wish to manually supply the argument to the calls to ``authenticate``,
25 | you can use the following snippet in your custom login views, tests, or other code::
26 |
27 |
28 | def custom_login_view(request)
29 | username = ...
30 | password = ...
31 |
32 | user = authenticate(
33 | request=request, # this is the important custom argument
34 | username=username,
35 | password=password,
36 | )
37 |
38 | if user is not None:
39 | login(request, user)
40 |
41 |
42 | If your test setup has problems with the ``request`` argument, you can either
43 | supply the argument manually with a blank `HttpRequest()`` object,
44 | disable Axes in the test setup by excluding ``axes`` from ``INSTALLED_APPS``,
45 | or leave out ``axes.backends.AxesBackend`` from your ``AUTHENTICATION_BACKENDS``.
46 |
47 | If you are using a 3rd party library that does not supply the ``request`` attribute
48 | when calling ``authenticate`` you can implement a customized backend that inherits
49 | from ``axes.backends.AxesBackend`` or other backend and overrides the ``authenticate`` method.
50 |
51 |
52 | Resetting attempts and lockouts
53 | -------------------------------
54 |
55 | When Axes locks an IP address, it is not allowed to login again.
56 | You can allow IPs to attempt again by resetting (deleting)
57 | the relevant AccessAttempt records in the admin UI, CLI, or your own code.
58 |
59 | You can also configure automatic cool down periods, IP whitelists, and custom
60 | code and handler functions for resetting attempts. Please check out the
61 | configuration and customization documentation for further information.
62 |
63 | .. note::
64 | Please note that the functionality describe here concerns the default
65 | database handler. If you have changed the default handler to another
66 | class such as the cache handler you have to implement custom reset commands.
67 |
68 |
69 | Resetting attempts from the Django admin UI
70 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
71 |
72 | Records can be easily deleted by using the Django admin application.
73 |
74 | Go to the admin UI and check the ``Access Attempt`` view.
75 | Select the attempts you wish the allow again and simply remove them.
76 | The blocked user will be allowed to log in again in accordance to the rules.
77 |
78 |
79 | Resetting attempts from command line
80 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
81 |
82 | Axes offers a command line interface with
83 | ``axes_reset``, ``axes_reset_ip``, ``axes_reset_username``, and ``axes_reset_ip_username``
84 | management commands with the Django ``manage.py`` or ``django-admin`` command helpers:
85 |
86 | - ``python manage.py axes_reset``
87 | will reset all lockouts and access records.
88 | - ``python manage.py axes_reset_ip [ip ...]``
89 | will clear lockouts and records for the given IP addresses.
90 | - ``python manage.py axes_reset_username [username ...]``
91 | will clear lockouts and records for the given usernames.
92 | - ``python manage.py axes_reset_ip_username [ip] [username]``
93 | will clear lockouts and records for the given IP address and username.
94 | - ``python manage.py axes_reset_logs (age)``
95 | will reset (i.e. delete) AccessLog records that are older
96 | than the given age where the default is 30 days.
97 |
98 |
99 | Resetting attempts programmatically by APIs
100 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
101 |
102 | In your code, you can use the ``axes.utils.reset`` function.
103 |
104 | - ``reset()`` will reset all lockouts and access records.
105 | - ``reset(ip=ip)`` will clear lockouts and records for the given IP address.
106 | - ``reset(username=username)`` will clear lockouts and records for the given username.
107 |
108 | .. note::
109 | Please note that if you give both ``username`` and ``ip`` arguments to ``reset``
110 | that attempts that have both the set IP and username are reset.
111 | The effective behaviour of ``reset`` is to ``and`` the terms instead of ``or`` ing them.
112 |
113 |
114 |
115 | Data privacy and GDPR
116 | ---------------------
117 |
118 | Most European countries have quite strict laws regarding data protection and privacy. It's highly recommended and good
119 | practice to treat your sensitive user data with care. The general rule here is that you shouldn't store what you don't need.
120 |
121 | When dealing with brute-force protection, the IP address and the username (often the email address) are most crucial.
122 | Given that you can perfectly use `django-axes` without locking the user out by IP but by username, it does make sense to
123 | avoid storing the IP address at all. You can not lose what you don't have.
124 |
125 | You can adjust the AXES settings as follows::
126 |
127 | # Block by Username only (i.e.: Same user different IP is still blocked, but different user same IP is not)
128 | AXES_LOCKOUT_PARAMETERS = ["username"]
129 |
130 | # Disable logging the IP-Address of failed login attempts by returning None for attempts to get the IP
131 | # Ignore assigning a lambda function to a variable for brevity
132 | AXES_CLIENT_IP_CALLABLE = lambda x: None # noqa: E731
133 |
--------------------------------------------------------------------------------
/tests/test_attempts.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import patch
2 |
3 | from django.http import HttpRequest
4 | from django.test import override_settings, RequestFactory
5 | from django.utils.timezone import now
6 |
7 | from axes.attempts import get_cool_off_threshold
8 | from axes.models import AccessAttempt
9 | from axes.utils import reset, reset_request
10 | from tests.base import AxesTestCase
11 |
12 |
13 | class GetCoolOffThresholdTestCase(AxesTestCase):
14 | @override_settings(AXES_COOLOFF_TIME=42)
15 | def test_get_cool_off_threshold(self):
16 | timestamp = now()
17 |
18 | request = RequestFactory().post("/")
19 | with patch("axes.attempts.now", return_value=timestamp):
20 | request.axes_attempt_time = timestamp
21 | threshold_now = get_cool_off_threshold(request)
22 |
23 | request.axes_attempt_time = None
24 | threshold_none = get_cool_off_threshold(request)
25 |
26 | self.assertEqual(threshold_now, threshold_none)
27 |
28 | @override_settings(AXES_COOLOFF_TIME=None)
29 | def test_get_cool_off_threshold_error(self):
30 | with self.assertRaises(TypeError):
31 | get_cool_off_threshold()
32 |
33 |
34 | class ResetTestCase(AxesTestCase):
35 | def test_reset(self):
36 | self.create_attempt()
37 | reset()
38 | self.assertFalse(AccessAttempt.objects.count())
39 |
40 | def test_reset_ip(self):
41 | self.create_attempt(ip_address=self.ip_address)
42 | reset(ip=self.ip_address)
43 | self.assertFalse(AccessAttempt.objects.count())
44 |
45 | def test_reset_username(self):
46 | self.create_attempt(username=self.username)
47 | reset(username=self.username)
48 | self.assertFalse(AccessAttempt.objects.count())
49 |
50 |
51 | class ResetResponseTestCase(AxesTestCase):
52 | USERNAME_1 = "foo_username"
53 | USERNAME_2 = "bar_username"
54 | IP_1 = "127.1.0.1"
55 | IP_2 = "127.1.0.2"
56 |
57 | def setUp(self):
58 | super().setUp()
59 | self.create_attempt()
60 | self.create_attempt(username=self.USERNAME_1, ip_address=self.IP_1)
61 | self.create_attempt(username=self.USERNAME_1, ip_address=self.IP_2)
62 | self.create_attempt(username=self.USERNAME_2, ip_address=self.IP_1)
63 | self.create_attempt(username=self.USERNAME_2, ip_address=self.IP_2)
64 | self.request = HttpRequest()
65 |
66 | def test_reset(self):
67 | reset_request(self.request)
68 | self.assertEqual(AccessAttempt.objects.count(), 5)
69 |
70 | def test_reset_ip(self):
71 | self.request.META["REMOTE_ADDR"] = self.IP_1
72 | reset_request(self.request)
73 | self.assertEqual(AccessAttempt.objects.count(), 3)
74 |
75 | def test_reset_username(self):
76 | self.request.GET["username"] = self.USERNAME_1
77 | reset_request(self.request)
78 | self.assertEqual(AccessAttempt.objects.count(), 5)
79 |
80 | def test_reset_ip_username(self):
81 | self.request.GET["username"] = self.USERNAME_1
82 | self.request.META["REMOTE_ADDR"] = self.IP_1
83 | reset_request(self.request)
84 | self.assertEqual(AccessAttempt.objects.count(), 3)
85 |
86 | @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
87 | def test_reset_user_failures(self):
88 | reset_request(self.request)
89 | self.assertEqual(AccessAttempt.objects.count(), 5)
90 |
91 | @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
92 | def test_reset_ip_user_failures(self):
93 | self.request.META["REMOTE_ADDR"] = self.IP_1
94 | reset_request(self.request)
95 | self.assertEqual(AccessAttempt.objects.count(), 5)
96 |
97 | @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
98 | def test_reset_username_user_failures(self):
99 | self.request.GET["username"] = self.USERNAME_1
100 | reset_request(self.request)
101 | self.assertEqual(AccessAttempt.objects.count(), 3)
102 |
103 | @override_settings(AXES_LOCKOUT_PARAMETERS=["username"])
104 | def test_reset_ip_username_user_failures(self):
105 | self.request.GET["username"] = self.USERNAME_1
106 | self.request.META["REMOTE_ADDR"] = self.IP_1
107 | reset_request(self.request)
108 | self.assertEqual(AccessAttempt.objects.count(), 3)
109 |
110 | @override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
111 | def test_reset_user_or_ip(self):
112 | reset_request(self.request)
113 | self.assertEqual(AccessAttempt.objects.count(), 5)
114 |
115 | @override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
116 | def test_reset_ip_user_or_ip(self):
117 | self.request.META["REMOTE_ADDR"] = self.IP_1
118 | reset_request(self.request)
119 | self.assertEqual(AccessAttempt.objects.count(), 3)
120 |
121 | @override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
122 | def test_reset_username_user_or_ip(self):
123 | self.request.GET["username"] = self.USERNAME_1
124 | reset_request(self.request)
125 | self.assertEqual(AccessAttempt.objects.count(), 3)
126 |
127 | @override_settings(AXES_LOCKOUT_PARAMETERS=["username", "ip_address"])
128 | def test_reset_ip_username_user_or_ip(self):
129 | self.request.GET["username"] = self.USERNAME_1
130 | self.request.META["REMOTE_ADDR"] = self.IP_1
131 | reset_request(self.request)
132 | self.assertEqual(AccessAttempt.objects.count(), 2)
133 |
134 | @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
135 | def test_reset_user_and_ip(self):
136 | reset_request(self.request)
137 | self.assertEqual(AccessAttempt.objects.count(), 5)
138 |
139 | @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
140 | def test_reset_ip_user_and_ip(self):
141 | self.request.META["REMOTE_ADDR"] = self.IP_1
142 | reset_request(self.request)
143 | self.assertEqual(AccessAttempt.objects.count(), 3)
144 |
145 | @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
146 | def test_reset_username_user_and_ip(self):
147 | self.request.GET["username"] = self.USERNAME_1
148 | reset_request(self.request)
149 | self.assertEqual(AccessAttempt.objects.count(), 3)
150 |
151 | @override_settings(AXES_LOCKOUT_PARAMETERS=[["username", "ip_address"]])
152 | def test_reset_ip_username_user_and_ip(self):
153 | self.request.GET["username"] = self.USERNAME_1
154 | self.request.META["REMOTE_ADDR"] = self.IP_1
155 | reset_request(self.request)
156 | self.assertEqual(AccessAttempt.objects.count(), 4)
157 |
--------------------------------------------------------------------------------
/tests/base.py:
--------------------------------------------------------------------------------
1 | from random import choice
2 | from string import ascii_letters, digits
3 | from time import sleep
4 |
5 | from django.contrib.auth import get_user_model
6 | from django.contrib.auth.base_user import AbstractBaseUser
7 | from django.http import HttpRequest
8 | from django.test import TestCase
9 | from django.urls import reverse
10 | from django.utils.timezone import now
11 |
12 | from axes.conf import settings
13 | from axes.helpers import (
14 | get_cache,
15 | get_client_http_accept,
16 | get_client_ip_address,
17 | get_client_path_info,
18 | get_client_user_agent,
19 | get_cool_off,
20 | get_credentials,
21 | get_failure_limit,
22 | )
23 | from axes.models import AccessAttempt, AccessLog, AccessFailureLog
24 | from axes.utils import reset
25 |
26 |
27 | def custom_failure_limit(request, credentials):
28 | return 3
29 |
30 |
31 | class AxesTestCase(TestCase):
32 | """
33 | Test case using custom settings for testing.
34 | """
35 |
36 | VALID_USERNAME = "axes-valid-username"
37 | VALID_PASSWORD = "axes-valid-password"
38 | VALID_EMAIL = "axes-valid-email@example.com"
39 | VALID_USER_AGENT = "axes-user-agent"
40 | VALID_IP_ADDRESS = "127.0.0.1"
41 |
42 | INVALID_USERNAME = "axes-invalid-username"
43 | INVALID_PASSWORD = "axes-invalid-password"
44 | INVALID_EMAIL = "axes-invalid-email@example.com"
45 |
46 | LOCKED_MESSAGE = "Account locked: too many login attempts."
47 | LOGOUT_MESSAGE = "Logged out"
48 | LOGIN_FORM_KEY = ''
49 |
50 | STATUS_SUCCESS = 200
51 | ALLOWED = 302
52 | BLOCKED = 429
53 |
54 | def setUp(self):
55 | """
56 | Create a valid user for login.
57 | """
58 |
59 | self.username = self.VALID_USERNAME
60 | self.password = self.VALID_PASSWORD
61 | self.email = self.VALID_EMAIL
62 |
63 | self.ip_address = self.VALID_IP_ADDRESS
64 | self.user_agent = self.VALID_USER_AGENT
65 | self.path_info = reverse("admin:login")
66 |
67 | self.user = get_user_model().objects.create_superuser(
68 | username=self.username, password=self.password, email=self.email
69 | )
70 |
71 | self.request = HttpRequest()
72 | self.request.method = "POST"
73 | self.request.META["REMOTE_ADDR"] = self.ip_address
74 | self.request.META["HTTP_USER_AGENT"] = self.user_agent
75 | self.request.META["PATH_INFO"] = self.path_info
76 |
77 | self.request.axes_attempt_time = now()
78 | self.request.axes_ip_address = get_client_ip_address(self.request)
79 | self.request.axes_user_agent = get_client_user_agent(self.request)
80 | self.request.axes_path_info = get_client_path_info(self.request)
81 | self.request.axes_http_accept = get_client_http_accept(self.request)
82 | self.request.axes_failures_since_start = None
83 | self.request.axes_locked_out = False
84 |
85 | self.credentials = get_credentials(self.username)
86 |
87 | def tearDown(self):
88 | get_cache().clear()
89 |
90 | def get_kwargs_with_defaults(self, **kwargs):
91 | defaults = {
92 | "user_agent": self.user_agent,
93 | "ip_address": self.ip_address,
94 | "username": self.username,
95 | }
96 |
97 | defaults.update(kwargs)
98 | return defaults
99 |
100 | def create_attempt(self, **kwargs):
101 | kwargs = self.get_kwargs_with_defaults(**kwargs)
102 | kwargs.setdefault("failures_since_start", 1)
103 | return AccessAttempt.objects.create(**kwargs)
104 |
105 | def create_log(self, **kwargs):
106 | return AccessLog.objects.create(**self.get_kwargs_with_defaults(**kwargs))
107 |
108 | def create_failure_log(self, **kwargs):
109 | return AccessFailureLog.objects.create(**self.get_kwargs_with_defaults(**kwargs))
110 |
111 | def reset(self, ip=None, username=None):
112 | return reset(ip, username)
113 |
114 | def login(
115 | self,
116 | is_valid_username=False,
117 | is_valid_password=False,
118 | remote_addr=None,
119 | **kwargs
120 | ):
121 | """
122 | Login a user.
123 |
124 | A valid credential is used when is_valid_username is True,
125 | otherwise it will use a random string to make a failed login.
126 | """
127 |
128 | if is_valid_username:
129 | username = self.VALID_USERNAME
130 | else:
131 | username = "".join(choice(ascii_letters + digits) for _ in range(10))
132 |
133 | if is_valid_password:
134 | password = self.VALID_PASSWORD
135 | else:
136 | password = self.INVALID_PASSWORD
137 |
138 | post_data = {"username": username, "password": password, **kwargs}
139 |
140 | return self.client.post(
141 | reverse("admin:login"),
142 | post_data,
143 | REMOTE_ADDR=remote_addr or self.ip_address,
144 | HTTP_USER_AGENT=self.user_agent,
145 | )
146 |
147 | def logout(self):
148 | return self.client.post(
149 | reverse("admin:logout"),
150 | REMOTE_ADDR=self.ip_address,
151 | HTTP_USER_AGENT=self.user_agent,
152 | )
153 |
154 | def check_login(self):
155 | response = self.login(is_valid_username=True, is_valid_password=True)
156 | self.assertNotContains(
157 | response, self.LOGIN_FORM_KEY, status_code=self.ALLOWED, html=True
158 | )
159 |
160 | def almost_lockout(self):
161 | for _ in range(1, get_failure_limit(None, None)):
162 | response = self.login()
163 | self.assertContains(response, self.LOGIN_FORM_KEY, html=True)
164 |
165 | def lockout(self):
166 | self.almost_lockout()
167 | return self.login()
168 |
169 | def check_lockout(self):
170 | response = self.lockout()
171 | if settings.AXES_LOCK_OUT_AT_FAILURE == True:
172 | self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
173 | else:
174 | self.assertNotContains(
175 | response, self.LOCKED_MESSAGE, status_code=self.STATUS_SUCCESS
176 | )
177 |
178 | def cool_off(self):
179 | sleep(get_cool_off().total_seconds())
180 |
181 | def check_logout(self):
182 | response = self.logout()
183 | self.assertContains(
184 | response, self.LOGOUT_MESSAGE, status_code=self.STATUS_SUCCESS
185 | )
186 |
187 | def check_handler(self):
188 | """
189 | Check a handler and its basic functionality with lockouts, cool offs, login, and logout.
190 |
191 | This is a check that is intended to successfully run for each and every new handler.
192 | """
193 |
194 | self.check_lockout()
195 | self.cool_off()
196 | self.check_login()
197 | self.check_logout()
198 |
--------------------------------------------------------------------------------
/axes/handlers/cache.py:
--------------------------------------------------------------------------------
1 | from logging import getLogger
2 | from typing import Optional
3 |
4 | from axes.conf import settings
5 | from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler
6 | from axes.helpers import (
7 | get_cache,
8 | get_cache_timeout,
9 | get_client_cache_keys,
10 | get_client_str,
11 | get_client_username,
12 | get_credentials,
13 | get_failure_limit,
14 | get_lockout_parameters,
15 | )
16 | from axes.models import AccessAttempt
17 | from axes.signals import user_locked_out
18 |
19 | log = getLogger(__name__)
20 |
21 |
22 | class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
23 | """
24 | Signal handler implementation that records user login attempts to cache and locks users out if necessary.
25 | """
26 |
27 | def __init__(self):
28 | self.cache = get_cache()
29 |
30 | def reset_attempts(
31 | self,
32 | *,
33 | ip_address: Optional[str] = None,
34 | username: Optional[str] = None,
35 | ip_or_username: bool = False,
36 | ) -> int:
37 | cache_keys: list = []
38 | count = 0
39 |
40 | if ip_address is None and username is None:
41 | raise NotImplementedError("Cannot clear all entries from cache")
42 | if ip_or_username:
43 | raise NotImplementedError(
44 | "Due to the cache key ip_or_username=True is not supported"
45 | )
46 |
47 | cache_keys.extend(
48 | get_client_cache_keys(
49 | AccessAttempt(username=username, ip_address=ip_address)
50 | )
51 | )
52 |
53 | for cache_key in cache_keys:
54 | deleted = self.cache.delete(cache_key)
55 | count += int(deleted) if deleted is not None else 1
56 |
57 | log.info("AXES: Reset %d access attempts from database.", count)
58 |
59 | return count
60 |
61 | def get_failures(self, request, credentials: Optional[dict] = None) -> int:
62 | cache_keys = get_client_cache_keys(request, credentials)
63 | failure_count = max(
64 | self.cache.get(cache_key, default=0) for cache_key in cache_keys
65 | )
66 | return failure_count
67 |
68 | def user_login_failed(self, sender, credentials: dict, request=None, **kwargs):
69 | """
70 | When user login fails, save attempt record in cache and lock user out if necessary.
71 |
72 | :raises AxesSignalPermissionDenied: if user should be locked out.
73 | """
74 |
75 | if request is None:
76 | log.error(
77 | "AXES: AxesCacheHandler.user_login_failed does not function without a request."
78 | )
79 | return
80 |
81 | username = get_client_username(request, credentials)
82 | lockout_parameters = get_lockout_parameters(request, credentials)
83 | if lockout_parameters == ["username"] and username is None:
84 | log.warning(
85 | "AXES: Username is None and username is the only one lockout parameter, new record will NOT be created."
86 | )
87 | return
88 |
89 | # If axes denied access, don't record the failed attempt as that would reset the lockout time.
90 | if (
91 | not settings.AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT
92 | and request.axes_locked_out
93 | ):
94 | request.axes_credentials = credentials
95 | user_locked_out.send(
96 | "axes",
97 | request=request,
98 | username=username,
99 | ip_address=request.axes_ip_address,
100 | )
101 | return
102 |
103 | client_str = get_client_str(
104 | username,
105 | request.axes_ip_address,
106 | request.axes_user_agent,
107 | request.axes_path_info,
108 | request,
109 | )
110 |
111 | if self.is_whitelisted(request, credentials):
112 | log.info("AXES: Login failed from whitelisted client %s.", client_str)
113 | return
114 |
115 | cache_keys = get_client_cache_keys(request, credentials)
116 | cache_timeout = get_cache_timeout(request)
117 | failures = []
118 | for cache_key in cache_keys:
119 | added = self.cache.add(key=cache_key, value=1, timeout=cache_timeout)
120 | if added:
121 | failures.append(1)
122 | else:
123 | failures.append(self.cache.incr(key=cache_key, delta=1))
124 | self.cache.touch(key=cache_key, timeout=cache_timeout)
125 |
126 | failures_since_start = max(failures)
127 | request.axes_failures_since_start = failures_since_start
128 |
129 | if failures_since_start > 1:
130 | log.warning(
131 | "AXES: Repeated login failure by %s. Count = %d of %d. Updating existing record in the cache.",
132 | client_str,
133 | failures_since_start,
134 | get_failure_limit(request, credentials),
135 | )
136 | else:
137 | log.warning(
138 | "AXES: New login failure by %s. Creating new record in the cache.",
139 | client_str,
140 | )
141 |
142 | if (
143 | settings.AXES_LOCK_OUT_AT_FAILURE
144 | and failures_since_start >= get_failure_limit(request, credentials)
145 | ):
146 | log.warning(
147 | "AXES: Locking out %s after repeated login failures.", client_str
148 | )
149 |
150 | request.axes_locked_out = True
151 | request.axes_credentials = credentials
152 | user_locked_out.send(
153 | "axes",
154 | request=request,
155 | username=username,
156 | ip_address=request.axes_ip_address,
157 | )
158 |
159 | def user_logged_in(self, sender, request, user, **kwargs):
160 | """
161 | When user logs in, update the AccessLog related to the user.
162 | """
163 |
164 | username = user.get_username()
165 | credentials = get_credentials(username)
166 | client_str = get_client_str(
167 | username,
168 | request.axes_ip_address,
169 | request.axes_user_agent,
170 | request.axes_path_info,
171 | request,
172 | )
173 |
174 | log.info("AXES: Successful login by %s.", client_str)
175 |
176 | if settings.AXES_RESET_ON_SUCCESS:
177 | cache_keys = get_client_cache_keys(request, credentials)
178 | for cache_key in cache_keys:
179 | failures_since_start = self.cache.get(cache_key, default=0)
180 | self.cache.delete(cache_key)
181 | log.info(
182 | "AXES: Deleted %d failed login attempts by %s from cache.",
183 | failures_since_start,
184 | client_str,
185 | )
186 |
187 | def user_logged_out(self, sender, request, user, **kwargs):
188 | username = user.get_username() if user else None
189 | client_str = get_client_str(
190 | username,
191 | request.axes_ip_address,
192 | request.axes_user_agent,
193 | request.axes_path_info,
194 | request,
195 | )
196 |
197 | log.info("AXES: Successful logout by %s.", client_str)
198 |
--------------------------------------------------------------------------------
/axes/conf.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.contrib.auth import get_user_model
3 | from django.utils.translation import gettext_lazy as _
4 |
5 | # disable plugin when set to False
6 | settings.AXES_ENABLED = getattr(settings, "AXES_ENABLED", True)
7 |
8 | # see if the user has overridden the failure limit
9 | settings.AXES_FAILURE_LIMIT = getattr(settings, "AXES_FAILURE_LIMIT", 3)
10 |
11 | # see if the user has set axes to lock out logins after failure limit
12 | settings.AXES_LOCK_OUT_AT_FAILURE = getattr(settings, "AXES_LOCK_OUT_AT_FAILURE", True)
13 |
14 | # lockout parameters
15 | # default value will be ["ip_address"] after removing AXES_LOCK_OUT params support
16 | settings.AXES_LOCKOUT_PARAMETERS = getattr(settings, "AXES_LOCKOUT_PARAMETERS", None)
17 |
18 | # TODO: remove it in future versions
19 | if settings.AXES_LOCKOUT_PARAMETERS is None:
20 | if getattr(settings, "AXES_ONLY_USER_FAILURES", False):
21 | settings.AXES_LOCKOUT_PARAMETERS = ["username"]
22 | else:
23 | if getattr(settings, "AXES_LOCK_OUT_BY_USER_OR_IP", False):
24 | settings.AXES_LOCKOUT_PARAMETERS = ["username", "ip_address"]
25 | elif getattr(settings, "AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP", False):
26 | settings.AXES_LOCKOUT_PARAMETERS = [["username", "ip_address"]]
27 | else:
28 | settings.AXES_LOCKOUT_PARAMETERS = ["ip_address"]
29 |
30 | if getattr(settings, "AXES_USE_USER_AGENT", False):
31 | if isinstance(settings.AXES_LOCKOUT_PARAMETERS[0], str):
32 | settings.AXES_LOCKOUT_PARAMETERS[0] = [
33 | settings.AXES_LOCKOUT_PARAMETERS[0],
34 | "user_agent",
35 | ]
36 | else:
37 | settings.AXES_LOCKOUT_PARAMETERS[0].append("user_agent")
38 |
39 | # lock out just for admin site
40 | settings.AXES_ONLY_ADMIN_SITE = getattr(settings, "AXES_ONLY_ADMIN_SITE", False)
41 |
42 | # show Axes logs in admin
43 | settings.AXES_ENABLE_ADMIN = getattr(settings, "AXES_ENABLE_ADMIN", True)
44 |
45 | # use a specific username field to retrieve from login POST data
46 | settings.AXES_USERNAME_FORM_FIELD = getattr(
47 | settings, "AXES_USERNAME_FORM_FIELD", get_user_model().USERNAME_FIELD
48 | )
49 |
50 | # use a specific password field to retrieve from login POST data
51 | settings.AXES_PASSWORD_FORM_FIELD = getattr(
52 | settings, "AXES_PASSWORD_FORM_FIELD", "password"
53 | ) # noqa
54 |
55 | # use a provided callable to transform the POSTed username into the one used in credentials
56 | settings.AXES_USERNAME_CALLABLE = getattr(settings, "AXES_USERNAME_CALLABLE", None)
57 |
58 | # determine if given user should be always allowed to attempt authentication
59 | settings.AXES_WHITELIST_CALLABLE = getattr(settings, "AXES_WHITELIST_CALLABLE", None)
60 |
61 | # return custom lockout response if configured
62 | settings.AXES_LOCKOUT_CALLABLE = getattr(settings, "AXES_LOCKOUT_CALLABLE", None)
63 |
64 | # use a provided callable to get client ip address
65 | settings.AXES_CLIENT_IP_CALLABLE = getattr(settings, "AXES_CLIENT_IP_CALLABLE", None)
66 |
67 | # reset the number of failed attempts after one successful attempt
68 | settings.AXES_RESET_ON_SUCCESS = getattr(settings, "AXES_RESET_ON_SUCCESS", False)
69 |
70 | settings.AXES_DISABLE_ACCESS_LOG = getattr(settings, "AXES_DISABLE_ACCESS_LOG", False)
71 |
72 | settings.AXES_ENABLE_ACCESS_FAILURE_LOG = getattr(
73 | settings, "AXES_ENABLE_ACCESS_FAILURE_LOG", False
74 | )
75 |
76 | settings.AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT = getattr(
77 | settings, "AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT", 1000
78 | )
79 |
80 | settings.AXES_HANDLER = getattr(
81 | settings, "AXES_HANDLER", "axes.handlers.database.AxesDatabaseHandler"
82 | )
83 |
84 | settings.AXES_LOCKOUT_TEMPLATE = getattr(settings, "AXES_LOCKOUT_TEMPLATE", None)
85 |
86 | settings.AXES_LOCKOUT_URL = getattr(settings, "AXES_LOCKOUT_URL", None)
87 |
88 | settings.AXES_COOLOFF_TIME = getattr(settings, "AXES_COOLOFF_TIME", None)
89 |
90 | settings.AXES_USE_ATTEMPT_EXPIRATION = getattr(settings, "AXES_USE_ATTEMPT_EXPIRATION", False)
91 |
92 | settings.AXES_VERBOSE = getattr(settings, "AXES_VERBOSE", settings.AXES_ENABLED)
93 |
94 | # whitelist and blacklist
95 | settings.AXES_NEVER_LOCKOUT_WHITELIST = getattr(
96 | settings, "AXES_NEVER_LOCKOUT_WHITELIST", False
97 | )
98 |
99 | settings.AXES_NEVER_LOCKOUT_GET = getattr(settings, "AXES_NEVER_LOCKOUT_GET", False)
100 |
101 | settings.AXES_ONLY_WHITELIST = getattr(settings, "AXES_ONLY_WHITELIST", False)
102 |
103 | settings.AXES_IP_WHITELIST = getattr(settings, "AXES_IP_WHITELIST", None)
104 |
105 | settings.AXES_IP_BLACKLIST = getattr(settings, "AXES_IP_BLACKLIST", None)
106 |
107 | # message to show when locked out and have cooloff enabled
108 | settings.AXES_COOLOFF_MESSAGE = getattr(
109 | settings,
110 | "AXES_COOLOFF_MESSAGE",
111 | _("Account locked: too many login attempts. Please try again later."),
112 | )
113 |
114 | # message to show when locked out and have cooloff disabled
115 | settings.AXES_PERMALOCK_MESSAGE = getattr(
116 | settings,
117 | "AXES_PERMALOCK_MESSAGE",
118 | _(
119 | "Account locked: too many login attempts. Contact an admin to unlock your account."
120 | ),
121 | )
122 |
123 | # set CORS allowed origins when calling authentication over ajax
124 | settings.AXES_ALLOWED_CORS_ORIGINS = getattr(settings, "AXES_ALLOWED_CORS_ORIGINS", "*")
125 |
126 | # set the list of sensitive parameters to cleanse from get/post data before logging
127 | settings.AXES_SENSITIVE_PARAMETERS = getattr(
128 | settings,
129 | "AXES_SENSITIVE_PARAMETERS",
130 | ["username", "ip_address"],
131 | )
132 |
133 | # set the callable for the readable string that can be used in
134 | # e.g. logging to distinguish client requests
135 | settings.AXES_CLIENT_STR_CALLABLE = getattr(settings, "AXES_CLIENT_STR_CALLABLE", None)
136 |
137 | # set the HTTP response code given by too many requests
138 | settings.AXES_HTTP_RESPONSE_CODE = getattr(settings, "AXES_HTTP_RESPONSE_CODE", 429)
139 |
140 | # If True, a failed login attempt during lockout will reset the cool off period
141 | settings.AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT = getattr(
142 | settings, "AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT", True
143 | )
144 |
145 |
146 | ###
147 | # django-ipware settings for client IP address calculation and proxy detection
148 | # there are old AXES_PROXY_ and AXES_META_ legacy keys present for backwards compatibility
149 | # see https://github.com/un33k/django-ipware for further details
150 | ###
151 |
152 | # if your deployment is using reverse proxies, set this value to 'left-most' or 'right-most' per your configuration
153 | settings.AXES_IPWARE_PROXY_ORDER = getattr(
154 | settings,
155 | "AXES_IPWARE_PROXY_ORDER",
156 | getattr(settings, "AXES_PROXY_ORDER", "left-most"),
157 | )
158 |
159 | # if your deployment is using reverse proxies, set this value to the number of proxies in front of Django
160 | settings.AXES_IPWARE_PROXY_COUNT = getattr(
161 | settings,
162 | "AXES_IPWARE_PROXY_COUNT",
163 | getattr(settings, "AXES_PROXY_COUNT", None),
164 | )
165 |
166 | # if your deployment is using reverse proxies, set to your trusted proxy IP addresses prefixes if needed
167 | settings.AXES_IPWARE_PROXY_TRUSTED_IPS = getattr(
168 | settings,
169 | "AXES_IPWARE_PROXY_TRUSTED_IPS",
170 | getattr(settings, "AXES_PROXY_TRUSTED_IPS", None),
171 | )
172 |
173 | # set to the names of request.META attributes that should be checked for the IP address of the client
174 | # if your deployment is using reverse proxies, ensure that the header attributes are securely set by the proxy
175 | # ensure that the client can not spoof the headers by setting them and sending them through the proxy
176 | settings.AXES_IPWARE_META_PRECEDENCE_ORDER = getattr(
177 | settings,
178 | "AXES_IPWARE_META_PRECEDENCE_ORDER",
179 | getattr(
180 | settings,
181 | "AXES_META_PRECEDENCE_ORDER",
182 | getattr(settings, "IPWARE_META_PRECEDENCE_ORDER", ("REMOTE_ADDR",)),
183 | ),
184 | )
185 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | PAPER =
8 | BUILDDIR = _build
9 |
10 | # User-friendly check for sphinx-build
11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
12 | $(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/)
13 | endif
14 |
15 | # Internal variables.
16 | PAPEROPT_a4 = -D latex_paper_size=a4
17 | PAPEROPT_letter = -D latex_paper_size=letter
18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
19 | # the i18n builder cannot share the environment and doctrees with the others
20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
21 |
22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext
23 |
24 | help:
25 | @echo "Please use \`make ' where is one of"
26 | @echo " html to make standalone HTML files"
27 | @echo " dirhtml to make HTML files named index.html in directories"
28 | @echo " singlehtml to make a single large HTML file"
29 | @echo " pickle to make pickle files"
30 | @echo " json to make JSON files"
31 | @echo " htmlhelp to make HTML files and a HTML help project"
32 | @echo " qthelp to make HTML files and a qthelp project"
33 | @echo " applehelp to make an Apple Help Book"
34 | @echo " devhelp to make HTML files and a Devhelp project"
35 | @echo " epub to make an epub"
36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
37 | @echo " latexpdf to make LaTeX files and run them through pdflatex"
38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
39 | @echo " text to make text files"
40 | @echo " man to make manual pages"
41 | @echo " texinfo to make Texinfo files"
42 | @echo " info to make Texinfo files and run them through makeinfo"
43 | @echo " gettext to make PO message catalogs"
44 | @echo " changes to make an overview of all changed/added/deprecated items"
45 | @echo " xml to make Docutils-native XML files"
46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes"
47 | @echo " linkcheck to check all external links for integrity"
48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)"
49 | @echo " coverage to run coverage check of the documentation (if enabled)"
50 |
51 | clean:
52 | rm -rf $(BUILDDIR)/*
53 |
54 | html:
55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
56 | @echo
57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
58 |
59 | dirhtml:
60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
61 | @echo
62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
63 |
64 | singlehtml:
65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
66 | @echo
67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
68 |
69 | pickle:
70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
71 | @echo
72 | @echo "Build finished; now you can process the pickle files."
73 |
74 | json:
75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
76 | @echo
77 | @echo "Build finished; now you can process the JSON files."
78 |
79 | htmlhelp:
80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
81 | @echo
82 | @echo "Build finished; now you can run HTML Help Workshop with the" \
83 | ".hhp project file in $(BUILDDIR)/htmlhelp."
84 |
85 | qthelp:
86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
87 | @echo
88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \
89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoAxes.qhcp"
91 | @echo "To view the help file:"
92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoAxes.qhc"
93 |
94 | applehelp:
95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp
96 | @echo
97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp."
98 | @echo "N.B. You won't be able to view it unless you put it in" \
99 | "~/Library/Documentation/Help or install it in your application" \
100 | "bundle."
101 |
102 | devhelp:
103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
104 | @echo
105 | @echo "Build finished."
106 | @echo "To view the help file:"
107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoAxes"
108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoAxes"
109 | @echo "# devhelp"
110 |
111 | epub:
112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
113 | @echo
114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
115 |
116 | latex:
117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
118 | @echo
119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \
121 | "(use \`make latexpdf' here to do that automatically)."
122 |
123 | latexpdf:
124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
125 | @echo "Running LaTeX files through pdflatex..."
126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf
127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
128 |
129 | latexpdfja:
130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
131 | @echo "Running LaTeX files through platex and dvipdfmx..."
132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
134 |
135 | text:
136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
137 | @echo
138 | @echo "Build finished. The text files are in $(BUILDDIR)/text."
139 |
140 | man:
141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
142 | @echo
143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
144 |
145 | texinfo:
146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
147 | @echo
148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
149 | @echo "Run \`make' in that directory to run these through makeinfo" \
150 | "(use \`make info' here to do that automatically)."
151 |
152 | info:
153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
154 | @echo "Running Texinfo files through makeinfo..."
155 | make -C $(BUILDDIR)/texinfo info
156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
157 |
158 | gettext:
159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
160 | @echo
161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
162 |
163 | changes:
164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
165 | @echo
166 | @echo "The overview file is in $(BUILDDIR)/changes."
167 |
168 | linkcheck:
169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
170 | @echo
171 | @echo "Link check complete; look for any errors in the above output " \
172 | "or in $(BUILDDIR)/linkcheck/output.txt."
173 |
174 | doctest:
175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
176 | @echo "Testing of doctests in the sources finished, look at the " \
177 | "results in $(BUILDDIR)/doctest/output.txt."
178 |
179 | coverage:
180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage
181 | @echo "Testing of coverage in the sources finished, look at the " \
182 | "results in $(BUILDDIR)/coverage/python.txt."
183 |
184 | xml:
185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
186 | @echo
187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml."
188 |
189 | pseudoxml:
190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
191 | @echo
192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
193 |
--------------------------------------------------------------------------------
/axes/checks.py:
--------------------------------------------------------------------------------
1 | from django.core.checks import ( # pylint: disable=redefined-builtin
2 | Tags,
3 | Warning,
4 | register,
5 | )
6 | from django.utils.module_loading import import_string
7 |
8 | from axes.backends import AxesStandaloneBackend
9 | from axes.conf import settings
10 |
11 |
12 | class Messages:
13 | CACHE_INVALID = (
14 | "You are using the django-axes cache handler for login attempt tracking."
15 | " Your cache configuration is however invalid and will not work correctly with django-axes."
16 | " This can leave security holes in your login systems as attempts are not tracked correctly."
17 | " Reconfigure settings.AXES_CACHE and settings.CACHES per django-axes configuration documentation."
18 | )
19 | MIDDLEWARE_INVALID = (
20 | "You do not have 'axes.middleware.AxesMiddleware' in your settings.MIDDLEWARE."
21 | )
22 | BACKEND_INVALID = "You do not have 'axes.backends.AxesStandaloneBackend' or a subclass in your settings.AUTHENTICATION_BACKENDS."
23 | SETTING_DEPRECATED = "You have a deprecated setting {deprecated_setting} configured in your project settings"
24 | CALLABLE_INVALID = "{callable_setting} is not a valid callable."
25 | LOCKOUT_PARAMETERS_INVALID = (
26 | "AXES_LOCKOUT_PARAMETERS does not contain 'ip_address'."
27 | " This configuration allows attackers to bypass rate limits by rotating User-Agents or Cookies."
28 | )
29 |
30 |
31 | class Hints:
32 | CACHE_INVALID = None
33 | MIDDLEWARE_INVALID = None
34 | BACKEND_INVALID = "AxesModelBackend was renamed to AxesStandaloneBackend in django-axes version 5.0."
35 | SETTING_DEPRECATED = None
36 | CALLABLE_INVALID = None
37 | LOCKOUT_PARAMETERS_INVALID = "Add 'ip_address' to AXES_LOCKOUT_PARAMETERS."
38 |
39 |
40 | class Codes:
41 | CACHE_INVALID = "axes.W001"
42 | MIDDLEWARE_INVALID = "axes.W002"
43 | BACKEND_INVALID = "axes.W003"
44 | SETTING_DEPRECATED = "axes.W004"
45 | CALLABLE_INVALID = "axes.W005"
46 | LOCKOUT_PARAMETERS_INVALID = "axes.W006"
47 |
48 |
49 | @register(Tags.security, Tags.caches, Tags.compatibility)
50 | def axes_cache_check(app_configs, **kwargs): # pylint: disable=unused-argument
51 | axes_handler = getattr(settings, "AXES_HANDLER", "")
52 |
53 | axes_cache_key = getattr(settings, "AXES_CACHE", "default")
54 | axes_cache_config = settings.CACHES.get(axes_cache_key, {})
55 | axes_cache_backend = axes_cache_config.get("BACKEND", "")
56 |
57 | axes_cache_backend_incompatible = [
58 | "django.core.cache.backends.dummy.DummyCache",
59 | "django.core.cache.backends.locmem.LocMemCache",
60 | "django.core.cache.backends.filebased.FileBasedCache",
61 | ]
62 |
63 | warnings = []
64 |
65 | if axes_handler == "axes.handlers.cache.AxesCacheHandler":
66 | if axes_cache_backend in axes_cache_backend_incompatible:
67 | warnings.append(
68 | Warning(
69 | msg=Messages.CACHE_INVALID,
70 | hint=Hints.CACHE_INVALID,
71 | id=Codes.CACHE_INVALID,
72 | )
73 | )
74 |
75 | return warnings
76 |
77 |
78 | @register(Tags.security, Tags.compatibility)
79 | def axes_middleware_check(app_configs, **kwargs): # pylint: disable=unused-argument
80 | warnings = []
81 |
82 | if "axes.middleware.AxesMiddleware" not in settings.MIDDLEWARE:
83 | warnings.append(
84 | Warning(
85 | msg=Messages.MIDDLEWARE_INVALID,
86 | hint=Hints.MIDDLEWARE_INVALID,
87 | id=Codes.MIDDLEWARE_INVALID,
88 | )
89 | )
90 |
91 | return warnings
92 |
93 |
94 | @register(Tags.security, Tags.compatibility)
95 | def axes_backend_check(app_configs, **kwargs): # pylint: disable=unused-argument
96 | warnings = []
97 |
98 | found = False
99 | for name in settings.AUTHENTICATION_BACKENDS:
100 | try:
101 | backend = import_string(name)
102 | except ModuleNotFoundError as e:
103 | raise ModuleNotFoundError(
104 | "Can not find module path defined in settings.AUTHENTICATION_BACKENDS"
105 | ) from e
106 | except ImportError as e:
107 | raise ImportError(
108 | "Can not import backend class defined in settings.AUTHENTICATION_BACKENDS"
109 | ) from e
110 |
111 | if issubclass(backend, AxesStandaloneBackend):
112 | found = True
113 | break
114 |
115 | if not found:
116 | warnings.append(
117 | Warning(
118 | msg=Messages.BACKEND_INVALID,
119 | hint=Hints.BACKEND_INVALID,
120 | id=Codes.BACKEND_INVALID,
121 | )
122 | )
123 |
124 | return warnings
125 |
126 |
127 | @register(Tags.compatibility)
128 | def axes_deprecation_check(app_configs, **kwargs): # pylint: disable=unused-argument
129 | warnings = []
130 |
131 | deprecated_settings = [
132 | "AXES_DISABLE_SUCCESS_ACCESS_LOG",
133 | "AXES_LOGGER",
134 | # AXES_PROXY_ and AXES_META_ parameters were updated to more explicit
135 | # AXES_IPWARE_PROXY_ and AXES_IPWARE_META_ prefixes in version 6.x
136 | "AXES_PROXY_ORDER",
137 | "AXES_PROXY_COUNT",
138 | "AXES_PROXY_TRUSTED_IPS",
139 | "AXES_META_PRECEDENCE_ORDER",
140 | # AXES_ONLY_USER_FAILURES, AXES_USE_USER_AGENT and
141 | # AXES_LOCK_OUT parameters were replaced with AXES_LOCKOUT_PARAMETERS
142 | # in version 6.x
143 | "AXES_ONLY_USER_FAILURES",
144 | "AXES_LOCK_OUT_BY_USER_OR_IP",
145 | "AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP",
146 | "AXES_USE_USER_AGENT",
147 | ]
148 |
149 | for deprecated_setting in deprecated_settings:
150 | try:
151 | getattr(settings, deprecated_setting)
152 | warnings.append(
153 | Warning(
154 | msg=Messages.SETTING_DEPRECATED.format(
155 | deprecated_setting=deprecated_setting
156 | ),
157 | hint=None,
158 | id=Codes.SETTING_DEPRECATED,
159 | )
160 | )
161 | except AttributeError:
162 | pass
163 |
164 | return warnings
165 |
166 |
167 | @register(Tags.security)
168 | def axes_lockout_params_check(app_configs, **kwargs): # pylint: disable=unused-argument
169 | warnings = []
170 |
171 | lockout_params = getattr(settings, "AXES_LOCKOUT_PARAMETERS", None)
172 |
173 | if isinstance(lockout_params, (list, tuple)):
174 | has_ip = False
175 | for param in lockout_params:
176 | if param == "ip_address":
177 | has_ip = True
178 | break
179 | if isinstance(param, (list, tuple)) and "ip_address" in param:
180 | has_ip = True
181 | break
182 |
183 | if not has_ip:
184 | warnings.append(
185 | Warning(
186 | msg=Messages.LOCKOUT_PARAMETERS_INVALID,
187 | hint=Hints.LOCKOUT_PARAMETERS_INVALID,
188 | id=Codes.LOCKOUT_PARAMETERS_INVALID,
189 | )
190 | )
191 |
192 | return warnings
193 |
194 |
195 | @register
196 | def axes_conf_check(app_configs, **kwargs): # pylint: disable=unused-argument
197 | warnings = []
198 |
199 | callable_settings = [
200 | "AXES_CLIENT_IP_CALLABLE",
201 | "AXES_CLIENT_STR_CALLABLE",
202 | "AXES_LOCKOUT_CALLABLE",
203 | "AXES_USERNAME_CALLABLE",
204 | "AXES_WHITELIST_CALLABLE",
205 | "AXES_COOLOFF_TIME",
206 | "AXES_LOCKOUT_PARAMETERS",
207 | ]
208 |
209 | for callable_setting in callable_settings:
210 | value = getattr(settings, callable_setting)
211 | if not is_valid_callable(value):
212 | warnings.append(
213 | Warning(
214 | msg=Messages.CALLABLE_INVALID.format(
215 | callable_setting=callable_setting
216 | ),
217 | hint=Hints.CALLABLE_INVALID,
218 | id=Codes.CALLABLE_INVALID,
219 | )
220 | )
221 |
222 | return warnings
223 |
224 |
225 | def is_valid_callable(value) -> bool:
226 | if value is None:
227 | return True
228 |
229 | if callable(value):
230 | return True
231 |
232 | if isinstance(value, str):
233 | try:
234 | import_string(value)
235 | except ImportError:
236 | return False
237 |
238 | return True
--------------------------------------------------------------------------------
/docs/5_customization.rst:
--------------------------------------------------------------------------------
1 | .. customization:
2 |
3 | Customization
4 | =============
5 |
6 | Axes has multiple options for customization including customizing the
7 | attempt tracking and lockout handling logic and lockout response formatting.
8 |
9 | There are public APIs and the whole Axes tracking system is pluggable.
10 | You can swap the authentication backend, attempt tracker, failure handlers,
11 | database or cache backends and error formatters as you see fit.
12 |
13 | Check the API reference section for further inspiration on
14 | implementing custom authentication backends, middleware, and handlers.
15 |
16 | Axes uses the stock Django signals for login monitoring and
17 | can be customized and extended by using them correctly.
18 |
19 | Axes listens to the following signals from ``django.contrib.auth.signals`` to log access attempts:
20 |
21 | * ``user_logged_in``
22 | * ``user_logged_out``
23 | * ``user_login_failed``
24 |
25 | You can also use Axes with your own auth module, but you'll need
26 | to ensure that it sends the correct signals in order for Axes to
27 | log the access attempts.
28 |
29 |
30 | Customizing authentication views
31 | --------------------------------
32 |
33 | Here is a more detailed example of sending the necessary signals using
34 | and a custom auth backend at an endpoint that expects JSON
35 | requests. The custom authentication can be swapped out with ``authenticate``
36 | and ``login`` from ``django.contrib.auth``, but beware that those methods take
37 | care of sending the necessary signals for you, and there is no need to duplicate
38 | them as per the example.
39 |
40 | ``example/forms.py``::
41 |
42 | from django import forms
43 |
44 | class LoginForm(forms.Form):
45 | username = forms.CharField(max_length=128, required=True)
46 | password = forms.CharField(max_length=128, required=True)
47 |
48 | ``example/views.py``::
49 |
50 | from django.contrib.auth import signals
51 | from django.http import JsonResponse, HttpResponse
52 | from django.utils.decorators import method_decorator
53 | from django.views import View
54 | from django.views.decorators.csrf import csrf_exempt
55 |
56 | from axes.decorators import axes_dispatch
57 |
58 | from example.forms import LoginForm
59 | from example.authentication import authenticate, login
60 |
61 |
62 | @method_decorator(axes_dispatch, name='dispatch')
63 | @method_decorator(csrf_exempt, name='dispatch')
64 | class Login(View):
65 | """
66 | Custom login view that takes JSON credentials
67 | """
68 |
69 | http_method_names = ['post']
70 |
71 | def post(self, request):
72 | form = LoginForm(request.POST)
73 |
74 | if not form.is_valid():
75 | # inform django-axes of failed login
76 | signals.user_login_failed.send(
77 | sender=User,
78 | request=request,
79 | credentials={
80 | 'username': form.cleaned_data.get('username'),
81 | },
82 | )
83 | return HttpResponse(status=400)
84 |
85 | user = authenticate(
86 | request=request,
87 | username=form.cleaned_data.get('username'),
88 | password=form.cleaned_data.get('password'),
89 | )
90 |
91 | if user is not None:
92 | login(request, user)
93 |
94 | signals.user_logged_in.send(
95 | sender=User,
96 | request=request,
97 | user=user,
98 | )
99 |
100 | return JsonResponse({
101 | 'message':'success'
102 | }, status=200)
103 |
104 | # inform django-axes of failed login
105 | signals.user_login_failed.send(
106 | sender=User,
107 | request=request,
108 | credentials={
109 | 'username': form.cleaned_data.get('username'),
110 | },
111 | )
112 |
113 | return HttpResponse(status=403)
114 |
115 | ``urls.py``::
116 |
117 | from django.urls import path
118 | from example.views import Login
119 |
120 | urlpatterns = [
121 | path('login/', Login.as_view(), name='login'),
122 | ]
123 |
124 |
125 | Customizing username lookups
126 | ----------------------------
127 |
128 | In special cases, you may have the need to modify the username that is
129 | submitted before attempting to authenticate. For example, adding namespacing or
130 | removing client-set prefixes. In these cases, ``axes`` needs to know how to make
131 | these changes so that it can correctly identify the user without any form
132 | cleaning or validation. This is where the ``AXES_USERNAME_CALLABLE`` setting
133 | comes in. You can define how to make these modifications in a callable that
134 | takes a request object and a credentials dictionary,
135 | and provide that callable to ``axes`` via this setting.
136 |
137 | For example, a function like this could take a post body with something like
138 | ``username='prefixed-username'`` and ``namespace=my_namespace`` and turn it
139 | into ``my_namespace-username``:
140 |
141 | ``example/utils.py``::
142 |
143 | def get_username(request, credentials):
144 | username = credentials.get('username')
145 | namespace = credentials.get('namespace')
146 | return namespace + '-' + username
147 |
148 | ``settings.py``::
149 |
150 | AXES_USERNAME_CALLABLE = 'example.utils.get_username'
151 |
152 | .. note::
153 | You still have to make these modifications yourself before calling
154 | authenticate. If you want to re-use the same function for consistency, that's
155 | fine, but Axes does not inject these changes into the authentication flow
156 | for you.
157 |
158 | Customizing lockout responses
159 | -----------------------------
160 |
161 | Axes can be configured with ``AXES_LOCKOUT_CALLABLE`` to return a custom lockout response when using the plugin with e.g. DRF (Django REST Framework) or other third party libraries which require specialized formats such as JSON or XML response formats or customized response status codes.
162 |
163 | An example of usage could be e.g. a custom view for processing lockouts.
164 |
165 | ``example/views.py``::
166 |
167 | from django.http import JsonResponse
168 |
169 | def lockout(request, response, credentials, *args, **kwargs):
170 | return JsonResponse({"status": "Locked out due to too many login failures"}, status=403)
171 |
172 | ``settings.py``::
173 |
174 | AXES_LOCKOUT_CALLABLE = "example.views.lockout"
175 |
176 | .. _customizing-lockout-parameters:
177 |
178 | Customizing lockout parameters
179 | ------------------------------
180 |
181 | Axes can be configured with ``AXES_LOCKOUT_PARAMETERS`` to lock out users not only by IP address.
182 |
183 | ``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.
184 |
185 | Example ``AXES_LOCKOUT_PARAMETERS`` configuration:
186 |
187 | ``settings.py``::
188 |
189 | AXES_LOCKOUT_PARAMETERS = ["ip_address", ["username", "user_agent"]]
190 |
191 | This way, axes will lock out users using ip_address and/or combination of username and user agent
192 |
193 | Example of callable ``AXES_LOCKOUT_PARAMETERS``:
194 |
195 | ``example/utils.py``::
196 |
197 | from django.http import HttpRequest
198 |
199 | def get_lockout_parameters(request_or_attempt, credentials):
200 |
201 | if isinstance(request_or_attempt, HttpRequest):
202 | is_localhost = request.META.get("REMOTE_ADDR") == "127.0.0.1"
203 |
204 | else:
205 | is_localhost = request_or_attempt.ip_address == "127.0.0.1"
206 |
207 | if is_localhost:
208 | return ["username"]
209 |
210 | return ["ip_address", "username"]
211 |
212 | ``settings.py``::
213 |
214 | AXES_LOCKOUT_PARAMETERS = "example.utils.get_lockout_parameters"
215 |
216 | 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.
217 |
218 | Customizing client ip address lookups
219 | -------------------------------------
220 |
221 | Axes can be configured with ``AXES_CLIENT_IP_CALLABLE`` to use custom client ip address lookup logic.
222 |
223 | ``example/utils.py``::
224 |
225 | def get_client_ip(request):
226 | return request.META.get("REMOTE_ADDR")
227 |
228 | ``settings.py``::
229 |
230 | AXES_CLIENT_IP_CALLABLE = "example.utils.get_client_ip"
231 |
--------------------------------------------------------------------------------
/axes/handlers/base.py:
--------------------------------------------------------------------------------
1 | import re
2 | from abc import ABC, abstractmethod
3 | from typing import Optional
4 | from warnings import warn
5 |
6 | from django.urls import reverse
7 | from django.urls.exceptions import NoReverseMatch
8 |
9 | from axes.conf import settings
10 | from axes.helpers import (
11 | get_failure_limit,
12 | is_client_ip_address_blacklisted,
13 | is_client_ip_address_whitelisted,
14 | is_client_method_whitelisted,
15 | is_user_attempt_whitelisted,
16 | )
17 |
18 |
19 | class AbstractAxesHandler(ABC):
20 | """
21 | Contract that all handlers need to follow
22 | """
23 |
24 | @abstractmethod
25 | def user_login_failed(self, sender, credentials: dict, request=None, **kwargs):
26 | """
27 | Handles the Django ``django.contrib.auth.signals.user_login_failed`` authentication signal.
28 | """
29 | raise NotImplementedError("user_login_failed should be implemented")
30 |
31 | @abstractmethod
32 | def user_logged_in(self, sender, request, user, **kwargs):
33 | """
34 | Handles the Django ``django.contrib.auth.signals.user_logged_in`` authentication signal.
35 | """
36 | raise NotImplementedError("user_logged_in should be implemented")
37 |
38 | @abstractmethod
39 | def user_logged_out(self, sender, request, user, **kwargs):
40 | """
41 | Handles the Django ``django.contrib.auth.signals.user_logged_out`` authentication signal.
42 | """
43 | raise NotImplementedError("user_logged_out should be implemented")
44 |
45 | @abstractmethod
46 | def get_failures(self, request, credentials: Optional[dict] = None) -> int:
47 | """
48 | Checks the number of failures associated to the given request and credentials.
49 |
50 | This is a virtual method that needs an implementation in the handler subclass
51 | if the ``settings.AXES_LOCK_OUT_AT_FAILURE`` flag is set to ``True``.
52 | """
53 | raise NotImplementedError("get_failures should be implemented")
54 |
55 |
56 | class AxesBaseHandler: # pylint: disable=unused-argument
57 | """
58 | Handler API definition for implementations that are used by the ``AxesProxyHandler``.
59 |
60 | If you wish to specialize your own handler class, override the necessary methods
61 | and configure the class for use by setting ``settings.AXES_HANDLER = 'module.path.to.YourClass'``.
62 | Make sure that new the handler is compliant with AbstractAxesHandler and make sure it extends from this mixin.
63 | Refer to `AxesHandler` for an example.
64 |
65 | The default implementation that is actually used by Axes is ``axes.handlers.database.AxesDatabaseHandler``.
66 |
67 | .. note:: This is a virtual class and **can not be used without specialization**.
68 | """
69 |
70 | def is_allowed(self, request, credentials: Optional[dict] = None) -> bool:
71 | """
72 | Checks if the user is allowed to access or use given functionality such as a login view or authentication.
73 |
74 | This method is abstract and other backends can specialize it as needed, but the default implementation
75 | checks if the user has attempted to authenticate into the site too many times through the
76 | Django authentication backends and returns ``False`` if user exceeds the configured Axes thresholds.
77 |
78 | This checker can implement arbitrary checks such as IP whitelisting or blacklisting,
79 | request frequency checking, failed attempt monitoring or similar functions.
80 |
81 | Please refer to the ``axes.handlers.database.AxesDatabaseHandler`` for the default implementation
82 | and inspiration on some common checks and access restrictions before writing your own implementation.
83 | """
84 |
85 | if settings.AXES_ONLY_ADMIN_SITE and not self.is_admin_request(request):
86 | return True
87 |
88 | if self.is_blacklisted(request, credentials):
89 | return False
90 |
91 | if self.is_whitelisted(request, credentials):
92 | return True
93 |
94 | if self.is_locked(request, credentials):
95 | return False
96 |
97 | return True
98 |
99 | def is_blacklisted(self, request, credentials: Optional[dict] = None) -> bool:
100 | """
101 | Checks if the request or given credentials are blacklisted from access.
102 | """
103 |
104 | if is_client_ip_address_blacklisted(request):
105 | return True
106 |
107 | return False
108 |
109 | def is_whitelisted(self, request, credentials: Optional[dict] = None) -> bool:
110 | """
111 | Checks if the request or given credentials are whitelisted for access.
112 | """
113 |
114 | if is_user_attempt_whitelisted(request, credentials):
115 | return True
116 |
117 | if is_client_ip_address_whitelisted(request):
118 | return True
119 |
120 | if is_client_method_whitelisted(request):
121 | return True
122 |
123 | return False
124 |
125 | def is_locked(self, request, credentials: Optional[dict] = None) -> bool:
126 | """
127 | Checks if the request or given credentials are locked.
128 | """
129 |
130 | if settings.AXES_LOCK_OUT_AT_FAILURE:
131 | # get_failures will have to be implemented by each specialized handler
132 | return self.get_failures( # type: ignore
133 | request, credentials
134 | ) >= get_failure_limit(request, credentials)
135 |
136 | return False
137 |
138 | def get_admin_url(self) -> Optional[str]:
139 | """
140 | Returns admin url if exists, otherwise returns None
141 | """
142 | try:
143 | return reverse("admin:index")
144 | except NoReverseMatch:
145 | return None
146 |
147 | def is_admin_request(self, request) -> bool:
148 | """
149 | Checks that request located under admin site
150 | """
151 | if hasattr(request, "path"):
152 | admin_url = self.get_admin_url()
153 | return (
154 | admin_url is not None
155 | and re.match(f"^{admin_url}", request.path) is not None
156 | )
157 |
158 | return False
159 |
160 | def is_admin_site(self, request) -> bool:
161 | """
162 | Checks if the request is NOT for admin site
163 | if `settings.AXES_ONLY_ADMIN_SITE` is True.
164 | """
165 | warn(
166 | (
167 | "This method is deprecated and will be removed in future versions. "
168 | "If you looking for method that checks if `request.path` located under "
169 | "admin site, use `is_admin_request` instead."
170 | ),
171 | DeprecationWarning,
172 | )
173 | if settings.AXES_ONLY_ADMIN_SITE and hasattr(request, "path"):
174 | try:
175 | admin_url = reverse("admin:index")
176 | except NoReverseMatch:
177 | return True
178 | return not re.match(f"^{admin_url}", request.path)
179 |
180 | return False
181 |
182 | def reset_attempts(
183 | self,
184 | *,
185 | ip_address: Optional[str] = None,
186 | username: Optional[str] = None,
187 | ip_or_username: bool = False,
188 | ) -> int:
189 | """
190 | Resets access attempts that match the given IP address or username.
191 |
192 | This method makes more sense for the DB backend, but as it is used by the ProxyHandler
193 | (via inherent), it needs to be defined here, so we get compliant with all proxy methods.
194 |
195 | Please overwrite it on each specialized handler as needed.
196 | """
197 | return 0
198 |
199 | def reset_logs(self, *, age_days: Optional[int] = None) -> int:
200 | """
201 | Resets access logs that are older than given number of days.
202 |
203 | This method makes more sense for the DB backend, but as it is used by the ProxyHandler
204 | (via inherent), it needs to be defined here, so we get compliant with all proxy methods.
205 |
206 | Please overwrite it on each specialized handler as needed.
207 | """
208 | return 0
209 |
210 | def reset_failure_logs(self, *, age_days: Optional[int] = None) -> int:
211 | """
212 | Resets access failure logs that are older than given number of days.
213 |
214 | This method makes more sense for the DB backend, but as it is used by the ProxyHandler
215 | (via inherent), it needs to be defined here, so we get compliant with all proxy methods.
216 |
217 | Please overwrite it on each specialized handler as needed.
218 | """
219 | return 0
220 |
221 | def remove_out_of_limit_failure_logs(
222 | self, *, username: str, limit: Optional[int] = None
223 | ) -> int:
224 | """Remove access failure logs that are over
225 | AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT for user username.
226 |
227 | This method makes more sense for the DB backend, but as it is used by the ProxyHandler
228 | (via inherent), it needs to be defined here, so we get compliant with all proxy methods.
229 |
230 | Please overwrite it on each specialized handler as needed.
231 |
232 | """
233 | return 0
234 |
235 |
236 | class AxesHandler(AbstractAxesHandler, AxesBaseHandler):
237 | """
238 | Signal bare handler implementation without any storage backend.
239 | """
240 |
241 | def user_login_failed(self, sender, credentials: dict, request=None, **kwargs):
242 | pass
243 |
244 | def user_logged_in(self, sender, request, user, **kwargs):
245 | pass
246 |
247 | def user_logged_out(self, sender, request, user, **kwargs):
248 | pass
249 |
250 | def get_failures(self, request, credentials: Optional[dict] = None) -> int:
251 | return 0
252 |
--------------------------------------------------------------------------------
/docs/6_integration.rst:
--------------------------------------------------------------------------------
1 | .. _integration:
2 |
3 | Integration
4 | ===========
5 |
6 | Axes is intended to be pluggable and usable with custom authentication solutions.
7 | This document describes the integration with some popular 3rd party packages
8 | such as Django Allauth, Django REST Framework, and other tools.
9 |
10 | In the following table
11 | **Compatible** means that a component should be fully compatible out-of-the-box,
12 | **Functional** means that a component should be functional after configuration, and
13 | **Incompatible** means that a component has been reported as non-functional with Axes.
14 |
15 | ======================= ============= ============ ============ ==============
16 | Project Version Compatible Functional Incompatible
17 | ======================= ============= ============ ============ ==============
18 | Django REST Framework |check|
19 | Django Allauth |check|
20 | Django Simple Captcha |check|
21 | Django OAuth Toolkit |check|
22 | Django Reversion |check|
23 | Django Auth LDAP |check|
24 | ======================= ============= ============ ============ ==============
25 |
26 | .. |check| unicode:: U+2713
27 | .. |lt| unicode:: U+003C
28 | .. |lte| unicode:: U+2264
29 | .. |gte| unicode:: U+2265
30 | .. |gt| unicode:: U+003E
31 |
32 | Please note that project compatibility depends on multiple different factors
33 | such as Django version, Axes version, and 3rd party package versions and
34 | their unique combinations per project.
35 |
36 | .. note::
37 | This documentation is mostly provided by Axes users.
38 | If you have your own compatibility tweaks and customizations
39 | that enable you to use Axes with other tools or have better
40 | implementations than the solutions provided here, please do
41 | feel free to open an issue or a pull request in GitHub!
42 |
43 |
44 | Integration with Django Allauth
45 | -------------------------------
46 |
47 | Axes relies on having login information stored under ``AXES_USERNAME_FORM_FIELD`` key
48 | both in ``request.POST`` and in ``credentials`` dict passed to
49 | ``user_login_failed`` signal.
50 |
51 | This is not the case with Allauth. Allauth always uses the ``login`` key in post POST data
52 | but it becomes ``username`` key in ``credentials`` dict in signal handler.
53 |
54 | To overcome this you need to use custom login form that duplicates the value
55 | of ``username`` key under a ``login`` key in that dict and set ``AXES_USERNAME_FORM_FIELD = 'login'``.
56 |
57 | You also need to decorate ``dispatch()`` and ``form_invalid()`` methods of the Allauth login view.
58 |
59 | ``settings.py``::
60 |
61 | AXES_USERNAME_FORM_FIELD = 'login'
62 |
63 | ``example/forms.py``::
64 |
65 | from allauth.account.forms import LoginForm
66 |
67 | class AxesLoginForm(LoginForm):
68 | """
69 | Extended login form class that supplied the
70 | user credentials for Axes compatibility.
71 | """
72 |
73 | def user_credentials(self):
74 | credentials = super().user_credentials()
75 | credentials['login'] = credentials.get('email') or credentials.get('username')
76 | return credentials
77 |
78 | ``example/urls.py``::
79 |
80 | from django.utils.decorators import method_decorator
81 |
82 | from allauth.account.views import LoginView
83 |
84 | from axes.decorators import axes_dispatch
85 | from axes.decorators import axes_form_invalid
86 |
87 | from example.forms import AxesLoginForm
88 |
89 | LoginView.dispatch = method_decorator(axes_dispatch)(LoginView.dispatch)
90 | LoginView.form_invalid = method_decorator(axes_form_invalid)(LoginView.form_invalid)
91 |
92 | urlpatterns = [
93 | # Override allauth default login view with a patched view
94 | path('accounts/login/', LoginView.as_view(form_class=AxesLoginForm), name='account_login'),
95 | path('accounts/', include('allauth.urls')),
96 | ]
97 |
98 |
99 | Integration with Django REST Framework
100 | --------------------------------------
101 |
102 | .. warning::
103 | The following guide only covers authentication schemes that rely on
104 | Django's ``authenticate()`` function. Other schemes (e.g.
105 | ``TokenAuthentication``) are currently not supported.
106 |
107 | Django Axes requires REST Framework to be connected
108 | via lockout signals for correct functionality.
109 |
110 | You can use the following snippet in your project signals such as ``example/signals.py``::
111 |
112 | from django.dispatch import receiver
113 |
114 | from axes.signals import user_locked_out
115 | from rest_framework.exceptions import PermissionDenied
116 |
117 |
118 | @receiver(user_locked_out)
119 | def raise_permission_denied(*args, **kwargs):
120 | raise PermissionDenied("Too many failed login attempts")
121 |
122 | And then configure your application to load it in ``examples/apps.py``::
123 |
124 | from django import apps
125 |
126 |
127 | class AppConfig(apps.AppConfig):
128 | name = "example"
129 |
130 | def ready(self):
131 | from example import signals # noqa
132 |
133 | Please check the Django signals documentation for more information:
134 |
135 | https://docs.djangoproject.com/en/3.2/topics/signals/
136 |
137 | When a user login fails a signal is emitted and PermissionDenied
138 | raises a HTTP 403 reply which interrupts the login process.
139 |
140 | This functionality was handled in the middleware for a time,
141 | but that resulted in extra database requests being made for
142 | each and every web request, and was migrated to signals.
143 |
144 |
145 | Integration with Django Simple Captcha
146 | --------------------------------------
147 |
148 | Axes supports Captcha with the Django Simple Captcha package in the following manner.
149 |
150 | ``settings.py``::
151 |
152 | AXES_LOCKOUT_URL = '/locked'
153 |
154 | ``example/urls.py``::
155 |
156 | url(r'^locked/$', locked_out, name='locked_out'),
157 |
158 | ``example/forms.py``::
159 |
160 | class AxesCaptchaForm(forms.Form):
161 | captcha = CaptchaField()
162 |
163 | ``example/views.py``::
164 |
165 | from axes.utils import reset_request
166 | from django.http.response import HttpResponseRedirect
167 | from django.shortcuts import render
168 | from django.urls import reverse_lazy
169 |
170 | from .forms import AxesCaptchaForm
171 |
172 |
173 | def locked_out(request):
174 | if request.POST:
175 | form = AxesCaptchaForm(request.POST)
176 | if form.is_valid():
177 | reset_request(request)
178 | return HttpResponseRedirect(reverse_lazy('auth_login'))
179 | else:
180 | form = AxesCaptchaForm()
181 |
182 | return render(request, 'accounts/captcha.html', {'form': form})
183 |
184 | ``example/templates/example/captcha.html``::
185 |
186 |
196 |
197 |
198 | Integration with Django OAuth Toolkit
199 | -------------------------------------
200 |
201 | Django OAuth toolkit is not designed to work with Axes,
202 | but some users have reported that they have configured
203 | validator classes to function correctly.
204 |
205 |
206 | ``example/validators.py``::
207 |
208 | from django.contrib.auth import authenticate
209 | from django.http import HttpRequest, QueryDict
210 |
211 | from oauth2_provider.oauth2_validators import OAuth2Validator
212 |
213 | from axes.helpers import get_client_ip_address, get_client_user_agent
214 |
215 |
216 | class AxesOAuth2Validator(OAuth2Validator):
217 | def validate_user(self, username, password, client, request, *args, **kwargs):
218 | """
219 | Check username and password correspond to a valid and active User
220 |
221 | Set defaults for necessary request object attributes for Axes compatibility.
222 | The ``request`` argument is not a Django ``HttpRequest`` object.
223 | """
224 |
225 | _request = request
226 | if request and not isinstance(request, HttpRequest):
227 | request = HttpRequest()
228 |
229 | request.uri = _request.uri
230 | request.method = request.http_method = _request.http_method
231 | request.META = request.headers = _request.headers
232 | request._params = _request._params
233 | request.decoded_body = _request.decoded_body
234 |
235 | request.axes_ip_address = get_client_ip_address(request)
236 | request.axes_user_agent = get_client_user_agent(request)
237 |
238 | body = QueryDict(str(_request.body), mutable=True)
239 | if request.method == 'GET':
240 | request.GET = body
241 | elif request.method == 'POST':
242 | request.POST = body
243 |
244 | user = authenticate(request=request, username=username, password=password)
245 | if user is not None and user.is_active:
246 | request = _request
247 | request.user = user
248 | return True
249 |
250 | return False
251 |
252 |
253 | ``settings.py``::
254 |
255 | OAUTH2_PROVIDER = {
256 | 'OAUTH2_VALIDATOR_CLASS': 'example.validators.AxesOAuth2Validator',
257 | 'SCOPES': {'read': 'Read scope', 'write': 'Write scope'},
258 | }
259 |
260 |
261 | Integration with Django Reversion
262 | ---------------------------------
263 |
264 | Django Reversion is not designed to work with Axes,
265 | but some users have reported that they have configured
266 | a workaround with a monkeypatch function that functions correctly.
267 |
268 | ``example/monkeypatch.py``::
269 |
270 | from django.urls import resolve
271 |
272 | from reversion import views
273 |
274 | def _request_creates_revision(request):
275 | view_name = resolve(request.path_info).url_name
276 | if view_name and view_name.endswith('login'):
277 | return False
278 |
279 | return request.method not in ["OPTIONS", "GET", "HEAD"]
280 |
281 | views._request_creates_revision = _request_creates_revision
282 |
--------------------------------------------------------------------------------