├── 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 |
187 | {% csrf_token %} 188 | 189 | {{ form.captcha.errors }} 190 | {{ form.captcha }} 191 | 192 |
193 | 194 |
195 |
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 | --------------------------------------------------------------------------------