django-axes-5.0.7/0000755000175000017500000000000013500726664012714 5ustar jamesjamesdjango-axes-5.0.7/pytest.ini0000644000175000017500000000031513500726664014744 0ustar jamesjames[pytest] addopts = --cov axes --cov-config .coveragerc --cov-append --cov-report term-missing python_files = tests.py test_*.py tests_*.py *_tests.py *_test.py DJANGO_SETTINGS_MODULE = axes.tests.settings django-axes-5.0.7/codecov.yml0000644000175000017500000000032413500726664015060 0ustar jamesjamescoverage: status: patch: off project: default: # Minimum test coverage required for pass target: 90% # Maximum test coverage change allowed for pass threshold: 20% django-axes-5.0.7/CHANGES.rst0000644000175000017500000004365013500726664014526 0ustar jamesjames Changes ======= 5.0.7 (2019-06-14) ------------------ - Fix lockout message showing when lockout is disabled with the ``AXES_LOCK_OUT_AT_FAILURE`` setting. [mogzol] - Add support for callable ``AXES_FAILURE_LIMIT`` setting. [bbayles] 5.0.6 (2019-05-25) ------------------ - Deprecate ``AXES_DISABLE_SUCCESS_ACCESS_LOG`` flag in favour of ``AXES_DISABLE_ACCESS_LOG`` which has mostly the same functionality. Update documentation to better reflect the behaviour of the flag. [aleksihakli] 5.0.5 (2019-05-19) ------------------ - Change the lockout response calculation to request flagging instead of exception throwing in the signal handler and middleware. Move request attribute calculation from middleware to handler layer. Deprecate ``axes.request.AxesHttpRequest`` object type definition. [aleksihakli] - Deprecate the old version 4.x ``axes.backends.AxesModelBackend`` class. [aleksihakli] - Improve documentation on attempt tracking, resets, Axes customization, project and component compatibility and integrations, and other things. [aleksihakli] 5.0.4 (2019-05-09) ------------------ - Fix regression with OAuth2 authentication backends not having remote IP addresses set and throwing an exception in cache key calculation. [aleksihakli] 5.0.3 (2019-05-08) ------------------ - Fix ``django.contrib.auth`` module ``login`` and ``logout`` functionality so that they work with the handlers without the an ``AxesHttpRequest`` to improve cross compatibility with other Django applications. [aleksihakli] - Change IP address resolution to allow empty or missing addresses. [aleksihakli] - Add error logging for missing request attributes in the handler layer so that users get better indicators of misconfigured applications. [aleksihakli] 5.0.2 (2019-05-07) ------------------ - Add ``AXES_ENABLED`` setting for disabling Axes with e.g. tests that use Django test client ``login``, ``logout``, and ``force_login`` methods, which do not supply the ``request`` argument to views, preventing Axes from functioning correctly in certain test setups. [aleksihakli] 5.0.1 (2019-05-03) ------------------ - Add changelog to documentation. [aleksihakli] 5.0 (2019-05-01) ---------------- - Deprecate Python 2.7, 3.4 and 3.5 support. [aleksihakli] - Remove automatic decoration and monkey-patching of Django views and forms. Decorators are available for login function and method decoration as before. [aleksihakli] - Use backend, middleware, and signal handlers for tracking login attempts and implementing user lockouts. [aleksihakli, jorlugaqui, joshua-s] - Add ``AxesDatabaseHandler``, ``AxesCacheHandler``, and ``AxesDummyHandler`` handler backends for processing user login and logout events and failures. Handlers are configurable with the ``AXES_HANDLER`` setting. [aleksihakli, jorlugaqui, joshua-s] - Improve management commands and separate commands for resetting all access attempts, attempts by IP, and attempts by username. New command names are ``axes_reset``, ``axes_reset_ip`` and ``axes_reset_username``. [aleksihakli] - Add support for string import for ``AXES_USERNAME_CALLABLE`` that supports dotted paths in addition to the old callable type such as a function or a class method. [aleksihakli] - Deprecate one argument call signature for ``AXES_USERNAME_CALLABLE``. From now on, the callable needs to accept two arguments, the HttpRequest and credentials that are supplied to the Django ``authenticate`` method in authentication backends. [aleksihakli] - Move ``axes.attempts.is_already_locked`` function to ``axes.handlers.AxesProxyHandler.is_locked``. Various other previously undocumented methods have been deprecated and moved inside the project. The new documented public APIs can be considered as stable and can be safely utilized by other projects. [aleksihakli] - Improve documentation layouting and contents. Add public API reference section. [aleksihakli] 4.5.4 (2019-01-15) ------------------ - Improve README and documentation [aleksihakli] 4.5.3 (2019-01-14) ------------------ - Remove the unused ``AccessAttempt.trusted`` flag from models [aleksihakli] - Improve README and Travis CI setups [aleksihakli] 4.5.2 (2019-01-12) ------------------ - Added Turkish translations [obayhan] 4.5.1 (2019-01-11) ------------------ - Removed duplicated check that was causing issues when using APIs. [camilonova] - Added Russian translations [lubicz-sielski] 4.5.0 (2018-12-25) ------------------ - Improve support for custom authentication credentials using the ``AXES_USERNAME_FORM_FIELD`` and ``AXES_USERNAME_CALLABLE`` settings. [mastacheata] - Updated behaviour for fetching username from request or credentials: If no ``AXES_USERNAME_CALLABLE`` is configured, the optional ``credentials`` that are supplied to the axes utility methods are now the default source for client username and the HTTP request POST is the fallback for fetching the user information. ``AXES_USERNAME_CALLABLE`` implements an alternative signature with two arguments ``request, credentials`` in addition to the old ``request`` call argument signature in a backwards compatible fashion. [aleksihakli] - Add official support for the Django 2.1 version and Python 3.7. [aleksihakli] - Improve the requirements, documentation, tests, and CI setup. [aleksihakli] 4.4.3 (2018-12-08) ------------------ - Fix MANIFEST.in missing German translations [aleksihakli] - Add `AXES_RESET_ON_SUCCESS` configuration flag [arjenzijlstra] 4.4.2 (2018-10-30) ------------------ - fix missing migration and add check to prevent it happening again. [markddavidoff] 4.4.1 (2018-10-24) ------------------ - Add a German translation [adonig] - Documentation wording changes [markddavidoff] - Use `get_client_username` in `log_user_login_failed` instead of credentials [markddavidoff] - pin prospector to 0.12.11, and pin astroid to 1.6.5 [hsiaoyi0504] 4.4.0 (2018-05-26) ------------------ - Added AXES_USERNAME_CALLABLE [jaadus] 4.3.1 (2018-04-21) ------------------ - Change custom authentication backend failures from error to warning log level [aleksihakli] - Set up strict code linting for CI pipeline that fails builds if linting does not pass [aleksihakli] - Clean up old code base and tests based on linter errors [aleksihakli] 4.3.0 (2018-04-21) ------------------ - Refactor and clean up code layout [aleksihakli] - Add prospector linting and code checks to toolchain [aleksihakli] - Clean up log message formatting and refactor type checks [EvaSDK] - Fix faulty user locking with user agent when AXES_ONLY_USER_FAILURES is set [EvaSDK] 4.2.1 (2018-04-18) ------------------ - Fix unicode string interpolation on Python 2.7 [aleksihakli] 4.2.0 (2018-04-13) ------------------ - Add configuration flags for client IP resolving [aleksihakli] - Add AxesModelBackend authentication backend [markdaviddoff] 4.1.0 (2018-02-18) ------------------ - Add AXES_CACHE setting for configuring `axes` specific caching. [JWvDronkelaar] - Add checks and tests for faulty LocMemCache usage in application setup. [aleksihakli] 4.0.2 (2018-01-19) ------------------ - Improve Windows compatibility on Python < 3.4 by utilizing win_inet_pton [hsiaoyi0504] - Add documentation on django-allauth integration [grucha] - Add documentation on known AccessAttempt caching configuration problems when using axes with the `django.core.cache.backends.locmem.LocMemCache` [aleksihakli] - Refactor and improve existing AccessAttempt cache reset utility [aleksihakli] 4.0.1 (2017-12-19) ------------------ - Fixes issue when not using `AXES_USERNAME_FORM_FIELD` [camilonova] 4.0.0 (2017-12-18) ------------------ - *BREAKING CHANGES*. `AXES_BEHIND_REVERSE_PROXY` `AXES_REVERSE_PROXY_HEADER` `AXES_NUM_PROXIES` were removed in order to use `django-ipware` to get the user ip address [camilonova] - Added support for custom username field [kakulukia] - Customizing Axes doc updated [pckapps] - Remove filtering by username [camilonova] - Fixed logging failed attempts to authenticate using a custom authentication backend. [D3X] 3.0.3 (2017-11-23) ------------------ - Test against Python 2.7. [mbaechtold] - Test against Python 3.4. [pope1ni] 3.0.2 (2017-11-21) ------------------ - Added form_invalid decorator. Fixes #265 [camilonova] 3.0.1 (2017-11-17) ------------------ - Fix DeprecationWarning for logger warning [richardowen] - Fixes global lockout possibility [joeribekker] - Changed the way output is handled in the management commands [ataylor32] 3.0.0 (2017-11-17) ------------------ - BREAKING CHANGES. Support for Django >= 1.11 and signals, see issue #215. Drop support for Python < 3.6 [camilonova] 2.3.3 (2017-07-20) ------------------ - Many tweaks and handles successful AJAX logins. [Jack Sullivan] - Add tests for proxy number parametrization [aleksihakli] - Add AXES_NUM_PROXIES setting [aleksihakli] - Log failed access attempts regardless of settings [jimr] - Updated configuration docs to include AXES_IP_WHITELIST [Minkey27] - Add test for get_cache_key function [jorlugaqui] - Delete cache key in reset command line [jorlugaqui] - Add signals for setting/deleting cache keys [jorlugaqui] 2.3.2 (2016-11-24) ------------------ - Only look for lockable users on a POST [schinckel] - Fix and add tests for IPv4 and IPv6 parsing [aleksihakli] 2.3.1 (2016-11-12) ------------------ - Added settings for disabling success accesslogs [Minkey27] - Fixed illegal IP address string passed to inet_pton [samkuehn] 2.3.0 (2016-11-04) ------------------ - Fixed ``axes_reset`` management command to skip "ip" prefix to command arguments. [EvaMarques] - Added ``axes_reset_user`` management command to reset lockouts and failed login records for given users. [vladimirnani] - Fixed Travis-PyPI release configuration. [jezdez] - Make IP position argument optional. [aredalen] - Added possibility to disable access log [svenhertle] - Fix for IIS used as reverse proxy adding port number [Dmitri-Sintsov] - Made the signal race condition safe. [Minkey27] - Added AXES_ONLY_USER_FAILURES to support only looking at the user ID. [lip77us] 2.2.0 (2016-07-20) ------------------ - Improve the logic when using a reverse proxy to avoid possible attacks. [camilonova] 2.1.0 (2016-07-14) ------------------ - Add `default_app_config` so you can just use `axes` in `INSTALLED_APPS` [vdboor] 2.0.0 (2016-06-24) ------------------ - Removed middleware to use app_config [camilonova] - Lots of cleaning [camilonova] - Improved test suite and versions [camilonova] 1.7.0 (2016-06-10) ------------------ - Use render shortcut for rendering LOCKOUT_TEMPLATE [Radoslaw Luter] - Added app_label for RemovedInDjango19Warning [yograterol] - Add iso8601 translator. [mullakhmetov] - Edit json response. Context now contains ISO 8601 formatted cooloff time [mullakhmetov] - Add json response and iso8601 tests. [mullakhmetov] - Fixes issue 162: UnicodeDecodeError on pip install [joeribekker] - Added AXES_NEVER_LOCKOUT_WHITELIST option to prevent certain IPs from being locked out. [joeribekker] 1.6.1 (2016-05-13) ------------------ - Fixes whitelist check when BEHIND_REVERSE_PROXY [Patrick Hagemeister] - Made migrations py3 compatible [mvdwaeter] - Fixing #126, possibly breaking compatibility with Django<=1.7 [int-ua] - Add note for upgrading users about new migration files [kelseyq] - Fixes #148 [camilonova] - Decorate auth_views.login only once [teeberg] - Set IP public/private classifier to be compliant with RFC 1918. [SilasX] - Issue #155. Lockout response status code changed to 403. [Arthur Mullahmetov] - BUGFIX: Missing migration [smeinel] 1.6.0 (2016-01-07) ------------------ - Stopped using render_to_response so that other template engines work [tarkatronic] - Improved performance & DoS prevention on query2str [tarkatronic] - Immediately return from is_already_locked if the user is not lockable [jdunck] - Iterate over ip addresses only once [annp89] - added initial migration files to support django 1.7 &up. Upgrading users should run migrate --fake-initial after update [ibaguio] - Add db indexes to CommonAccess model [Schweigi] 1.5.0 (2015-09-11) ------------------ - Fix #_get_user_attempts to include username when filtering AccessAttempts if AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP is True [afioca] 1.4.0 (2015-08-09) ------------------ - Send the user_locked_out signal. Fixes #94. [toabi] 1.3.9 (2015-02-11) ------------------ - Python 3 fix (#104) 1.3.8 (2014-10-07) ------------------ - Rename GitHub organization from django-security to django-pci to emphasize focus on providing assistance with building PCI compliant websites with Django. [aclark4life] 1.3.7 (2014-10-05) ------------------ - Explain common issues where Axes fails silently [cericoda] - Allow for user-defined username field for lookup in POST data [SteveByerly] - Log out only if user was logged in [zoten] - Support for floats in cooloff time (i.e: 0.1 == 6 minutes) [marianov] - Limit amount of POST data logged (#73). Limiting the length of value is not enough, as there could be arbitrary number of them, or very long key names. [peterkuma] - Improve get_ip to try for real ip address [7wonders] - Change IPAddressField to GenericIPAddressField. When using a PostgreSQL database and the client does not pass an IP address you get an inet error. This is a known problem with PostgreSQL and the IPAddressField. https://code.djangoproject.com/ticket/5622. It can be fixed by using a GenericIPAddressField instead. [polvoblanco] - Get first X-Forwarded-For IP [tutumcloud] - White listing IP addresses behind reverse proxy. Allowing some IP addresses to have direct access to the app even if they are behind a reverse proxy. Those IP addresses must still be on a white list. [ericbulloch] - Reduce logging of reverse proxy IP lookup and use configured logger. Fixes #76. Instead of logging the notice that django.axes looks for a HTTP header set by a reverse proxy on each attempt, just log it one-time on first module import. Also use the configured logger (by default axes.watch_login) for the message to be more consistent in logging. [eht16] - Limit the length of the values logged into the database. Refs #73 [camilonova] - Refactored tests to be more stable and faster [camilonova] - Clean client references [camilonova] - Fixed admin login url [camilonova] - Added django 1.7 for testing [camilonova] - Travis file cleanup [camilonova] - Remove hardcoded url path [camilonova] - Fixing tests for django 1.7 [Andrew-Crosio] - Fix for django 1.7 exception not existing [Andrew-Crosio] - Removed python 2.6 from testing [camilonova] - Use django built-in six version [camilonova] - Added six as requirement [camilonova] - Added python 2.6 for travis testing [camilonova] - Replaced u string literal prefixes with six.u() calls [amrhassan] - Fixes object type issue, response is not an string [camilonova] - Python 3 compatibility fix for db_reset [nicois] - Added example project and helper scripts [barseghyanartur] - Admin command to list login attemps [marianov] - Replaced six imports with django.utils.six ones [amrhassan] - Replaced u string literal prefixes with six.u() calls to make it compatible with Python 3.2 [amrhassan] - Replaced `assertIn`s and `assertNotIn`s with `assertContains` and `assertNotContains` [fcurella] - Added py3k to travis [fcurella] - Update test cases to be python3 compatible [nicois] - Python 3 compatibility fix for db_reset [nicois] - Removed trash from example urls [barseghyanartur] - Added django installer [barseghyanartur] - Added example project and helper scripts [barseghyanartur] 1.3.6 (2013-11-23) ------------------ - Added AttributeError in case get_profile doesn't exist [camilonova] - Improved axes_reset command [camilonova] 1.3.5 (2013-11-01) ------------------ - Fix an issue with __version__ loading the wrong version [graingert] 1.3.4 (2013-11-01) ------------------ - Update README.rst for PyPI [marty, camilonova, graingert] - Add cooloff period [visualspace] 1.3.3 (2013-07-05) ------------------ - Added 'username' field to the Admin table [bkvirendra] - Removed fallback logging creation since logging cames by default on django 1.4 or later, if you don't have it is because you explicitly wanted. Fixes #45 [camilonova] 1.3.2 (2013-03-28) ------------------ - Fix an issue when a user logout [camilonova] - Match pypi version [camilonova] - Better User model import method [camilonova] - Use only one place to get the version number [camilonova] - Fixed an issue when a user on django 1.4 logout [camilonova] - Handle exception if there is not user profile model set [camilonova] - Made some cleanup and remove a pokemon exception handling [camilonova] - Improved tests so it really looks for the rabbit in the hole [camilonova] - Match pypi version [camilonova] 1.3.1 (2013-03-19) ------------------ - Add support for Django 1.5 [camilonova] 1.3.0 (2013-02-27) ------------------ - Bug fix: get_version() format string [csghormley] 1.2.9 (2013-02-20) ------------------ - Add to and improve test cases [camilonova] 1.2.8 (2013-01-23) ------------------ - Increased http accept header length [jslatts] 1.2.7 (2013-01-17) ------------------ - Reverse proxy support [rmagee] - Clean up README [martey] 1.2.6 (2012-12-04) ------------------ - Remove unused import [aclark4life] 1.2.5 (2012-11-28) ------------------ - Fix setup.py [aclark4life] - Added ability to flag user accounts as unlockable. [kencochrane] - Added ipaddress as a param to the user_locked_out signal. [kencochrane] - Added a signal receiver for user_logged_out. [kencochrane] - Added a signal for when a user gets locked out. [kencochrane] - Added AccessLog model to log all access attempts. [kencochrane] django-axes-5.0.7/LICENSE0000644000175000017500000000224013500726664013717 0ustar jamesjamesThe MIT License Copyright (c) 2008 Josh VanderLinden Copyright (c) 2009 Philip Neustrom Copyright (c) 2016 Jazzband Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. django-axes-5.0.7/MANIFEST.in0000644000175000017500000000021013500726664014443 0ustar jamesjamesinclude LICENSE README.rst CHANGES.rst recursive-include axes *.py recursive-include axes/locale *.mo *.po recursive-include docs *.rst django-axes-5.0.7/README.rst0000644000175000017500000000546013500726664014410 0ustar jamesjames Django Axes =========== .. image:: https://jazzband.co/static/img/badge.svg :target: https://jazzband.co/ :alt: Jazzband .. image:: https://img.shields.io/github/stars/jazzband/django-axes.svg?label=Stars&style=socialcA :target: https://github.com/jazzband/django-axes :alt: GitHub .. image:: https://img.shields.io/pypi/v/django-axes.svg :target: https://pypi.org/project/django-axes/ :alt: PyPI release .. image:: https://img.shields.io/readthedocs/django-axes.svg :target: https://django-axes.readthedocs.io/ :alt: Documentation .. image:: https://secure.travis-ci.org/jazzband/django-axes.svg?branch=master :target: http://travis-ci.org/jazzband/django-axes :alt: Build Status .. image:: https://codecov.io/gh/jazzband/django-axes/branch/master/graph/badge.svg :target: https://codecov.io/gh/jazzband/django-axes :alt: Coverage .. image:: https://pyup.io/repos/github/jazzband/django-axes/shield.svg :target: https://pyup.io/repos/github/jazzband/django-axes/ :alt: Updates Axes is a very simple way for you to keep track of failed login attempts for your login and administration views. The name is sort of a geeky pun, since it can be interpreted as: * ``access``, as in monitoring access attempts, or * ``axes``, as in tools you can use to hack (generally on wood). In this case, however, the hacking part of it can be taken a bit further: **Axes is intended to help you stop people from hacking your website**. Functionality ------------- Axes records login attempts to your Django powered site and prevents attackers from brute forcing the site when they exceed the configured attempt limit. Axes can track the attempts and persist them in the database indefinitely, or alternatively use a fast and DDoS resistant cache implementation. Axes can be configured to monitor login attempts by IP address, username, user agent, or their combinations. Axes supports cool off periods, IP address whitelisting and blacklisting, user account whitelisting, and other features for Django access management. Documentation ------------- For more information on installation and configuration see the documentation at: https://django-axes.readthedocs.io/ Issues ------ If you have questions or have trouble using the app please file a bug report at: https://github.com/jazzband/django-axes/issues Contributions ------------- This is a `Jazzband `_ project. By contributing you agree to abide by the `Contributor Code of Conduct `_ and follow the `guidelines `_. It is best to separate proposed changes and PRs into small, distinct patches by type so that they can be merged faster into upstream and released quicker: * features, * bugfixes, * code style improvements, and * documentation improvements. django-axes-5.0.7/tox.ini0000644000175000017500000000101413500726664014223 0ustar jamesjames[tox] envlist = py{36,37}-django{111,21,22,master} [travis] python = 3.6: py36 3.7: py37 [travis:env] DJANGO = 1.11: django111 2.1: django21 2.2: django22 master: djangomaster [testenv] deps = -r requirements.txt django111: django>=1.11,<2.0 django21: django>=2.1,<2.2 django22: django>=2.2,<2.3 djangomaster: https://github.com/django/django/archive/master.tar.gz usedevelop = True commands = pytest prospector mypy axes setenv = PYTHONDONTWRITEBYTECODE=1 django-axes-5.0.7/.coveragerc0000644000175000017500000000004213500726664015031 0ustar jamesjames[run] branch = True source = axes django-axes-5.0.7/.travis.yml0000644000175000017500000000137713500726664015035 0ustar jamesjamesdist: xenial language: python cache: pip python: - 3.6 - 3.7 env: - DJANGO=1.11 - DJANGO=2.1 - DJANGO=2.2 - DJANGO=master matrix: allow_failures: - python: 3.6 env: DJANGO=master - python: 3.7 env: DJANGO=master fast_finish: true install: pip install tox-travis codecov script: tox after_success: - codecov deploy: provider: pypi user: jazzband password: secure: TCH5tGIggL2wsWce2svMwpEpPiwVOYqq1R3uSBTexszleP0OafNq/wZk2KZEReR5w1Aq68qp5F5Eeh2ZjJTq4f9M4LtTvqQzrmyNP55DYk/uB1rBJm9b4gBgMtAknxdI2g7unkhQEDo4suuPCVofM7rrDughySNpmvlUQYDttHQ= server: https://jazzband.co/projects/django-axes/upload distributions: sdist bdist_wheel skip_existing: true on: tags: true repo: jazzband/django-axes python: 3.6 django-axes-5.0.7/.gitignore0000644000175000017500000000021313500726664014700 0ustar jamesjames*.egg-info *.pyc *.swp .coverage .DS_Store .idea .mypy_cache/ .project .pydevproject .python-version .tox build/ dist/ docs/_build test.db django-axes-5.0.7/mypy.ini0000644000175000017500000000015113500726664014410 0ustar jamesjames[mypy] python_version = 3.6 ignore_missing_imports = True [mypy-axes.migrations.*] ignore_errors = True django-axes-5.0.7/docs/0000755000175000017500000000000013500726664013644 5ustar jamesjamesdjango-axes-5.0.7/docs/2_installation.rst0000644000175000017500000000776313500726664017335 0ustar jamesjames.. _installation: Installation ============ Axes is easy to install from the PyPI package:: $ pip install django-axes After installing the package, the project settings need to be configured. **1.** Add ``axes`` to your ``INSTALLED_APPS``:: INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', # Axes app can be in any position in the INSTALLED_APPS list. 'axes', ] **2.** Add ``axes.backends.AxesBackend`` to the top of ``AUTHENTICATION_BACKENDS``:: AUTHENTICATION_BACKENDS = [ # AxesBackend should be the first backend in the AUTHENTICATION_BACKENDS list. 'axes.backends.AxesBackend', # Django ModelBackend is the default authentication backend. 'django.contrib.auth.backends.ModelBackend', ] **3.** Add ``axes.middleware.AxesMiddleware`` to your list of ``MIDDLEWARE``:: MIDDLEWARE = [ # The following is the list of default middleware in new Django projects. 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', # AxesMiddleware should be the last middleware in the MIDDLEWARE list. # It only formats user lockout messages and renders Axes lockout responses # on failed user authentication attempts from login views. # If you do not want Axes to override the authentication response # you can skip installing the middleware and use your own views. 'axes.middleware.AxesMiddleware', ] **4.** Run ``python manage.py check`` to check the configuration. **5.** Run ``python manage.py migrate`` to sync the database. Axes is now functional with the default settings and is saving user attempts into your database and locking users out if they exceed the maximum attempts. You should use the ``python manage.py check`` command to verify the correct configuration in development, staging, and production environments. It is probably best to use this step as part of your regular CI workflows to verify that your project is not misconfigured. Axes uses checks to verify your Django settings configuration for security and functionality. Many people have different configurations for their development and production environments, and running the application with misconfigured settings can prevent security features from working. Disabling Axes system checks ---------------------------- If you are implementing custom authentication, request middleware, or signal handlers the Axes checks system might false positives in the Django checks framework. You can silence the unnecessary warnings by using the following Django settings:: SILENCED_SYSTEM_CHECKS = ['axes.W003'] Axes has the following warnings codes built in: - ``axes.W001`` for invalid ``CACHES`` configuration. - ``axes.W002`` for invalid ``MIDDLEWARE`` configuration. - ``axes.W003`` for invalid ``AUTHENTICATION_BACKENDS`` configuration. - ``axes.W004`` for deprecated use of ``AXES_*`` setting flags. .. note:: Only disable the Axes system checks and warnings if you know what you are doing. The default checks are implemented to verify and improve your project's security and should only produce necessary warnings due to misconfigured settings. Disabling Axes components in tests ---------------------------------- If you get errors when running tests, try setting the ``AXES_ENABLED`` flag to ``False`` in your test settings:: AXES_ENABLED = False This disables the Axes middleware, authentication backend and signal receivers, which might fix errors with incompatible test configurations. django-axes-5.0.7/docs/images/0000755000175000017500000000000013500726664015111 5ustar jamesjamesdjango-axes-5.0.7/docs/images/flow.png0000644000175000017500000040364513500726664016602 0ustar jamesjamesPNG  IHDR5{ }zTXtmxGraphModelMWǮ캎 ts:9L.sghJiY$μj膴ʫ, ~` A>GW8?WY_:UF?QR바?05kgu ȿ>SjbZ]`ɂ*?۪y-dI30C wg'伕=0Hwc-5T9o)OFLQ%r( *}"gu[y=w"]禛ZQ\\,2 981r' sf~fF#F?Js OLK#T䰫\@|j+EfpQȖJq?!ˆUή0Ϗ`#:R*IP>7 s9)RZJP?ey©J9KMQUvOn7i체K 4~VЌדRVKig9 |aXƹ?hT 7rv}q1XOfY^(຋Qh5>_kJ5klYU<{D(ό@C/ 2@!iiWC2A ]*.nA~>1@S0! U̹)O,wXr~ݒ`z‚tQ=h10T!gⱙu}`ONИa,8 8HҦν)N2)y*-6t=hOeaH9p/Me\2oF:f@1VFXwpٺQF9%ZE 8hj!) ᲟTq ~y6myf{j9\6N `&A"DƣmS^S=ŹK Hp-*: Ga^:\Wk AϸDZ!TQ>9֛' x/h"h3v }@azk֫õ4vϣ,-7S0| NdYj֟&HNØnMdn[RZt,.""7+nV#_6M DOqqpHf0҄ƉqKKK–I]KBtb7pVr97W0{+o2+JZQ^ݳuagƁ.CX+ novŭu@ܗ:Fѝrvsڣ}̓<{(kh.ɭVcfD4G֌liA(Â=˷(N0l[Dio}RoG-lqͽӗ`Q܄:uc=c3J=$|n-yx"V <H8Me$ h&|TE H;!qňgzpՠl`Şc2f%weVYC*$VŵiXd{ݷ %cצz 2E7?6Ô?ikL| v`pz8&ʋtE 2& $T ]M²N62p< %l3-=orBLͲʲHr;x RWaD=?9,a nY jڧOL:`x}F?K_^Q:ѱd}f]Q! BPd},D2!C:]#}e f|WρU$hY34Sb(:!*$2p&UXǦsɺyœޠϋ! 5E.&k[ՌOccaiN|{kOo-iu0f,%7S2N]Ze]KBq rN|9{sx -2FF/ H$#T,6׀7EТxl !g͊45{˄(͊nt hY2_55kJ/1x!Ae Y2Ph!vߘ#~*ouPU~{8B^ IDATx^K%0b0PgP@ԧ b€sF|&bDsV0 f+uٵ]TW:usNU_eTB@! 9E`>-B@! E@F B@! @B@! B@do?076 ,FJ6|FWnOӤI^'-Z;{:믿矿b7^TV/iӦVJfGzVF:=e>2_OdgzG㎳JҾ}{OÇՑzgoyfРA%֤!ykm[n }v3͚53wqGuW_}ex Ӷm[ :s˛ntA%7-i%oȘ1c{mIꫯ^ܜzﶘ[P?5\c =0KU~9 3p0kƏñknv_>eۗPݕ 5wwy֡|*Xs߾_~ٴi&UR3(O$9Q:ߚZ@BY(iaԆl&"5)zUH͢.jnV5 /fu1&M2LZˬj)PzRHkZ| &4xj֣! RS'?h&JoN.,${&HU,>[oJԄN0*fei\TJqLN8°{!-P Rֶzklٲ iӀjnfwT6>w7#GRlh/s9Ǧ+'p č?}(<}'j:y"~vI'Y|`ӗ_~mG{'ߝǘw~wdAԐG>O"G/D!Ep"+V_xKs8̸⋭ٶ=)뮻%1(x!',.,D#( k,дIφ= u"rC! 9@lժUW]e1=_aY.6(I;+_ 6dٳg[9h䅽3:de̼An#z7QĐP88m0~T- ;gF!QvO?cĸE Kc.b\2Ec|׬Cs]RÁW^y wo)Ɖybd# ˼ zL thqC@n.fLysb&[a9uv`Ա`|Tbz9ӦM} K#!cѓs9.󌹲[csse|@|tzc[Gj= n >;RCY#H@p=X: 0Bjަ8#0X C}wdXW^:5`D!vldu#t(&s=0fa^ym;0pA̓bmӈš,C ' +akH=A9k8L3D6p"Cq8a; ]خS|dμ>~:9u=1.&Ga3Cj`(a&'FԠYQ6xYHPVx[">`C$"&q1q†@^(H#xvyg7ꡟ1* މAA3x2A7 o0Xc y[6g׷> k!G&DF{`ucwXY Ÿ˃aK@q#xC )N< :u% a ٻy!RP@ ." #5(=5((nI;YXyυNщфq伽,ҤjzH @2N G%!znCvX$ xȍK?ssFq9%mCXTX8!K~^oa]6z]PYqٍYqb>#5NN3^b/긡1Ԝ=`. HH $v)`:L1fB1j݂ł̢DeSэv^RactAw"A.- O+,`F"A XH!c 8rH)x.Iqq b 5]T`8B»X|Qt^ "D(y3$t5#`pC!s<)Fj#8˘?.Wxoold;Rěn SX:F{Y7s;DX1B$MRk-k NGpvxxI kz„š-%5*S ϨEY{W+?!*[!tNIڌtR{I kW>Е#fX[wH QA OaBu?=0C_:p>8矋t R6gQ/ :NuQ(8&L&H XԸ4%:wBr^Rݯ 3k25>gp]H (ưr b3x6(ި'RH zQw ?"=ő ԋDžI](RÂg Хy)v{,@^2-j RE[K; ,ҙHpKc4d%["V(R?c|pV6H c¾ ;  Z7/(cT ANid)d)[qI4sߊ:QHMqFIq "5`g<DK'kRƍX%N #5a5BԄ4ƛB<$ $ )6 w5s! a0E'1r`Qu#H7M!*ΰq #5=]HOZx#Ynn;̉3_C2 %-/a>pc$$l 3 an8ʸsvIa-SH.I}'m?ݝ( AlqC@ZO/S^]ShmvмƉpƻv@/aa̼#]qA&l.Ȋ~:`?N:U.*㏂%5vn#ʇ @ 0bcw 0 ņpA0ܞds|:Ňa /Q?^ m hdži;J/l ڏׅ_X` o⌌BjXCT;t؋p8?~ QoOkUqI m`}zYY9Ԁv%z, %k;zqmfc#-~RHMDj9(ԐG4;&8X3޵;{(Ԅ%xQׯ08rSl[ c(?i~R;]ԑ#."-o&L?(d el.[r(Ը6ǝ@W]#aAE "5a-Qׯ0<+09JUQRŠ" e3&3JY oƢ7RR bxX8$ rwc<ъ NE,̻= 'p ^'5(J~sǤ_o; G$7XP)`D؂K(cMd,``2VL6Nbrg^#2ݦb&$k8q%(\S&;ߝ枇hbA(w RY &Ŏ^= 1۟}'2cSIԋS'NH$ 4Q>N7h9L`y`}?҂Q6cP@]JD>Y8R_O 뿧&xrY\^{?%HqF#!b0bnd3?'>"s;pQ9w?aE ~=vs^p٥a裳t)r|E}3W 0n I1㝫x♫| >bgD&wA85zܭ.,w6%/ݚ]pX[nOv fЬKu\{ h Ӂ~ˆ;|Ef?5Eu =Dj(h=M볊8& 54d}apq(gx߅yޑafgq混l:1N1S>p{Y qHqh}'~qcE8!A08b}gfAg]VaW#0$tX[/;Qpʸ99 R쁰:>fyDֱׂT˝KJ3i9 ï3Ij02QR;N9.U˟6jwN?2xT7R! D.띻 rDoH]"_5W@p R!Lw`Iy=.b<.d* IDATOߤUO! DjRn5bnϪ7ΛTk IRCܙI'łh8UK>'$bA]UK}T_B@ BƤo 1! @&IMu!ۅB@! "5y-U! B@yP! B@!kDjr=|jB@! "5! B@! rHMOB@! B@F2 B@! @B@! B@HB@! 5"5>5^! B@ɀB@! F@&ç ! B@! R#B@! B zx! B@! Dj$B@! B@\/B@! Hd@! B@\# RSㅀB@!  ! B@!kDjr=|jB@! "5! B@! rHMOB@! B@F2 B@! @B@! B@HB@! 5"5>5^! B@ɀB@! F@&ç ! B@! R#B@! B zx! B@! Dj$B@wi6mZX 0|=#? VK"5.5V CjZ+@*<裦sΩԭJ"5! RC@&5hU"525X \ +HMKi" R&[ɀ! RX ,rHMKB@&_ 4I]-Hd@I ZU,rHML B@&Wå |! RRk@ԤB@F2 @jԤ*C@&wC \! RRc@xB MDjDWu !P69ri%R#ӗ@*$iDjN:$;1cX%"@v ^{YgQjRH9c6xc! C5]vYe~5dzXTrؤ0ÇWz8Ppf3'pB !͏?h>OHIC@U(~Ej^s5CEE#@ QGevm46?͂ .oUFJ/KRK-esH_&i /HMƍ͜9sdtdf!P4oj8R&MLS ]$j/%&@)KdRߌ1Jk(}VZ#NQB@GZ7*߿袋4B@"ƍ myHM_( z@PȤf3Ϊ!?56"@(G;WTKo$b!=_썟Z$G RH"5I@6 ?aE& !]?qɏ($Jjn6ӫW$_"5Zu@ED]?($щJj8YI"@:+ԄB!~:@ RH$T]B _fϞ8P"5! !uRsG믿^(@ᠩB 'ı_ɠB(rnHN?Ku%*N?,z'/"5$k"G)$ HMh.!MtP@6E@EF\}!Q I$QW]BToMq엚'5?/kk? ?5u? G)$¨Zq|`\rIb%u%b#z NV/dָ,k[ű_j_[̢.j_~yӻwoӯ_?3sLkN;S5\cZ*w~{17p~5^{lF(!0?.SN5첋~gn:oFϛ/뭷9aHG1-[ }+U_dLjz!/gs駛_ߜ|f:Γ7:uqSj;K~B0zҾ}{Bgioڴif.W yTuqs͕VIT(j=tM>l^p@׻ufny+^x[: ǬjI4;3u̘1rn3Ӯ c,AH1cƘe]L2Ŵk<f 6'|bؤܲe˸8zq[_%-Rm!y`Yi,7 o>ї^zyH}MZ#{>FW!brKKYkQ Q\TRS|Ma?1Aꫯ9 H7s`7ݹ̸q :bWX RۙT;n6 U:RQ,!2]t5BVDjvYfvQR u֦ 2dnw8sGc`qfmfX`!5}i޼mxV[me?$:p6L813m*!q엚$5~Yao h 5,"DZ0b #1Gqu]x}YCy}u5(>}!0aiB11nxAY/2,cG_~ vi's[ҋ';syW^y*?R-h[JdJ~v1X;y: :1d #9|s9|7Vv{=;1nXd̙cwQ駟Πz= 2:(mAynfKDh's%xXin[o5HI@/+pM7d?W_?#G!czUWY3|Gaik%N?3&݁#e6C ), dw< 2&Ev k߰>C+FvBЕAm<&3.z gL#5ތ=:闿[la#5 /d`E6Fo-.{mAgK6m6V։SO=ڼ^k5 kh#v-ӢE XK{*W;/5IjP f~b$`brc;P l,$L\#I"ARXG<zL֠!/]v5{.3;U"AD;^_m ʁ+  х#5,nq/eS]%uԨQ֣ 78 >OAX!2d`EIzgg'uI'?cBb饗lOn !{<DQÇv{?L0oaSWVI;`P4H ץuA؃dCLɜd8(%+b<BOZ뮻\aD!I& R2\;۶mkD]B0΃wW+o90~xs>x0Wعku''I}S0! y\GWC7cbŠ##Y!`tgpϻ\k=Ǡ0`u u:M \X;3ދSa(袽J`,jw!I1Zke~{ŽL#0zW_}natD^!䍵9hEy N_lkz=sT3ۅX.A/cc't6(@XН@Kؒ4t%\b($MςlAHva Z7B?~xm.tC]\@X;Ѕ8ykoA*]/5Ij0`Y4Aͥ9R $)ix0𸲁g0|( ر8O~ c`"#]И<E0ʨvx ƒ u;R^.yᇭҡ`@H{ \XQn'Ep1?BŅ"G( 3rv[R6qB +*g. [`'5DABFa>'t3 p!rRHH6ÖyoZ8H F6 >|.,Dx0LÛx;02i@j [!?rHM}SPD RO~mKj .]X w+>b|t2Gsܩ - ҂As By,6# 8kH%Hv#5 -doE9FƐ uD OB =:yFꗷ`atӤ 58nF(0ǩ[SL߂l2f.:-9D>zݥGd bmd3nx1^Dر6_Ю!cwBhE"?i8KM&30tJw,c_AXkP,nwbpðI#oދg!`b1gFj0#>x 31*"a0hC\a"C\0x`Ǔ `ȤD 3x >L{bSP{ߍJj*}^4E'r/F@_F]*^I5D0W10 /HG!٧^R3y%1Y,]z'xl!H h<#L!^Ut{D7UE1XB{j!5tB)r ]BOJZE!Gj= 6Q;'5Nz69IZiRόgQGjXǓ;0q X˺E+(8MlwP -A4h'NƠ$3> RShOMƛ~VHDydr6?_tQqmlĺm늟ԠK#&ȮNSI2*.~AeءCOj X0xx=I C)+F0"~F $R@!@=,!;&l2OpATx‘<GE*YH[xq@P~"SH ɏx bOo"#8IR0}$1=v #x%AK~6.I ,))xn'# 2D?8 I~'5fqt?[ γ=޵F=QGjHJ^Y5"]A$Ybhк;Ry 2'MAc}3~?dƚeRSSmMy$u~ A3 ,l! [.8#2IH /dxuHM< V S#1tgweFWŽtv)$("ꠃ0;(H#s]zD1$B!p F w؃`BPw**'1dL Mu@P w9 D4:PuRSϐKE?{;Hgd*HF+r&U杛q<'5d, #//HuQȦ 'O="A\kW8 g9+vVOb WRI36C$`aG3` C"+8] =wROHK` «GNq`g'5ȱ4gэjw#q&ew`#`9 A"sd`P3X+8MtK7)yYoA✅ܰ6㐤-iRHg % :(܈)vIMF0%tdO"㽟cSق`dZ7}GjT;5^݆~)RY7J(W"<r?'&;F8=& ݔ}@ by3x:I 'ooZP0vDe,xSxIMO?s a37$9H"3AJzyҞ5oWH_1wGAC!*Ms`8Stz?/vM&Ԥ%̪`|zIMu[Q I,Ꞛj$#58'jP+#~$#5d @E< "&EZ]趂($;T$bHS) pӷjRr{RZ/"5ao \!G)$ٱҧ%G%~V c=aC?q:,A RH"5IY.]ǯ }^M KZY/uIjÉcؔ Q 1}h{ON[wޟp?QjIRߝQ I=*gIuq1Xp*p '~)Z=vJAOrmb'Y8AFt< ,QÆ^of{wd!.y7I|]]"5i\`-28GyK.I Ep2s$Aj H{}$.,>r (glm#@=u}gVCTR}S';s! IDATA1Q{*m#qM8dc iDDR |i9%#quwopI`X  tK_@u:eT(o3pdmx>1G8+#r qg$RÂܮ];;6xp;qS9\}(,܋<&C=#Bāﰀr̐!Cȑ#{x[0dJ{YexA9`s9 >kڷoozww!p-s['m^U G5^&<Ŝ1AqiKTy?pK;f5@{}\\x饗:s(\G=sG#y?Q&`l-?21'p }RJ>zNCp0'ܺ27~xc1뤒po:;ڃ2/u1ѳq5X]/ts‰A/t:7ADE%ǖؤ;r"/?\$]_s=\fJ߹;zA3,0Ԑp qD0P0tq?d@<nf)eZ$5%E yRř.:XPǸˑ.ca?o,䶳3O x=. aWAm'uI{O7yuRH?bFm`Bؙ+*`B!Ps g\)%;ዼtzN{ڴiv>}ΜG6X`3g?ls:18UHc8b0?88~Bx :ʹ9 >.ESx?$-$1ҥuR1F'.C/СAVnF/ qK8D0] qXwh*f}@/ =MI 36$1JH~-*E$ה81׏0RGo(ƶ#5!@cA@7S0 ʱȒb1.vàmqo'6lc#1xS;dСdi!C!o4` } <000őijRK0*c H@<0 )Sz083 D;b j;l Zc5l&歹uRpǨGV+n FQ,q~%XO:fDG{8+0`",V<ϢEj \4"!|n{F]`o s愻ۯ"dC gN9"2E; E $!8593< :IA4vP"=>@T+/( t s%7JУ%Y,:Q&  d3QǖzMZ$=3W[tEmz0 d礁B킠B.`BXW?4QR !|2aZHMF"*E",ڷo_(hBBʢWG-#9 1${3Nhgf"StS;m!Q/~rǾ`Bc Fri>aQK%&aB Jgh#4^08vPAjժ=l;Ew+@ dY ' :Pѝ) (:6AX)|FDbD . .RCJc- zJUnd~q0ɍ;2\t7 0 uzH, *{~}9V~SO*dwy>o(Ha[2'W7GmaϗwG(CD M)}D)I= QP,RːQC} 'At7 lhLRs3v]#ރ&oaR6q"V_d'Av.*1WO/C`PJͫRO! -%(JQ I9Y>,I($JjtY.!PYDj*& 8HM=IZQ I$QW]B⭷ zB "RSO-qB $D@ds\*!P ı_"v<䓵 jߨf /qQ@- ;?+>ܹssI?P-t?TGȤufw! )SL޽ɓ+訤f0M6xB! C4ݻwZFK!I/IM^̶nkzɎQB@#p5ט &E%5ZhO/B|̙c>T~ ]dKdRs%ߜwyZ%@ }5Z2GydJj&Md\pA[Tz#D)"5_C UC~ӤI\-B`~'ӢE 3{쪠f3yUک !PꪫLFB+:4z@dRX0_~y CG u.b?xӱcǪ>*q_|/_B@C`Сf6}j/H P_Ռ1"3WC hvj?pM7plGc9^w}wRμ/԰fu54쯽ڕUoB"O?̬+ ,/% $RC IdRzweYc_܌?>b! E`7}u,lf# L:@+P6u{;8?Nssv/.!yaw!BSaAY!5_2D!u@K٤ǣf=!Q#ARS%#D7n"5R]Բ_zaz门s"55SM6Q3D@&RMRY bB]P&! ^HMG0TUqH '9Զm[siN:&/ftIHU#@ "5 H! Dj@5u~ߚVZɼf5ְii~9novAψ7 :|Gk׮fذaW" RS]v!PGHMDX,Djb=p,`>3tzgzac.va槟~ CdFmȮ\vef]wUF@PWYl )ZHM-j$RS1k ;<3a3fۅ{ <ؼRV[y623qDӫW/3uTgQȑ#>W(j"553L GU#RSUr)E-DF~~6ϾxРAI&~jZk-7X3d3c {W}.B fO +PzDIFUC@D! R#YH)H@@& TWgic/;lkvm7K/=ü;믿6;[gkylu]g~3vV-A@~AHDj22nHMG6/RSWqI?綾^3HL˖-_|ak/3}tF>."YguW\aV^yM) RRDj$ HRC@&5h3_?`.bϚ3g͛mFa-,EfE9$jB`nDj$"5I LULc3'pKw5wq~MvdMId̲.kv[RVwJ?{HMƤ*-RYU`CHӸqcf͚Yҹsg{WL}3_~yɓMV~UZ,%T&T謿FJ!#s ?ݠOJYRtE]Ԓu],r6]mg# R1bddqT&J)dnHjA"5FNF{_6tSRvW 6]x7jqٳ텛)]-H~1U(LR,XDj$i R bDEzaK{Tk[l_FR޸|sO>'mG$'&Ux\ B.U BԤjru>3(k,Ga.6 o袋vؓT*HMep~/_ɂE@JA"5iwx饗C=d#?y a*t-Zػqlq K,aSh$HMF~&HDj$! R e6cC`;曛vFTCԐ'|\|6Et5z˫\߶HH4Q݊hrTMFу1c$zev}w^䔋M:+{.W^)"5e/ !HD" R#AH訾n3|1ǘ]Mlx㍣W'SAt?̘1Fvm7{9i^ w#[ȕp* /؍K/pt_^V7EjʂO_.N?h8Dj$ "5 =AL\.3}M'#STå1ӦM3~F[I;v|=h!s! p|m۶fĈf54o9Sɓ-C]vyɈX3䔭-;"5eWK_RN_Dj ^g7ΞPF:駟;T#e;0!r۷:6<ңG+=Z7p= X!3p=Lo7Æ ziq9#&lbpbi߿ڬz{~qdM4FQu !PEj$Ej$i PkM Rz,\ePufͲrچnh^zi;Mƍ){^{} :g϶<̶lxwP!ZDe:wl*Phhqz O܀뮖 F)"5QP3B@HMDjjl@3ҝ0`{챩ir-SN³iO?5믿;RsgX"CD~2#/{Ȧ]π@.RLb{oo:Ȓ)B!n뮻v"5x`c=f!DLc^zi<[om,RzC=FZH{ /A8ykt=w{͏?hOS4hflRHT7|s++2F7i~}.ʑM! R#H@ *q&J u ac  Ȍ{Bd/_~,lF0B7XRQ!P3f< Y@0(DveH?n:'Np_$5ӧO7 37t7Dɥ!J@lva3|pСC^RLT@'R=~dwz![׭(t{j D@fF-DEJ !P "5VA@o8NJ)hxIM"i@*ׄB2"[wo53gδ8:XO9ч#8ϒ҉\@,D/N>d17s 7RF|y"-%5D|Hcy7$#cٳg}X|>ļOj8U!oV"/A^{) 6}IDN!5?Oksb o<\01on6j~F$c )bD-ư[;ь3l Dh;6=5n'rCĆ%/p2!/'u󛔲e]֒+E{H Dۧ:pR#=Cv9H}́xI ĎI&YBG8 /ˈNA)_~ŇZ.AH9NSN64FRԲoBT 2L C5"IRΕW^iӕ00| A<'qmVR|o!B4ewW 1</zxt\|C!LYHE]ݻs?>ӢE =lg/2,c6l3ԐBq@NR094;hأ^/~+/[2F6D۴iS{* +`"Q?Y$ہ($C]"5I#" R#yH@ mRm3F+ H /?^yRj߁T!r+~ +`zr*D4sgf͚U(HX _6 犓Ոظt;o}A|h/"־<د gɃB wmHMpC: p'jE4ZOWu! BEDKQkxAF!N"kjjw A@~ƺhOEj$i PMRQ.wOikD t+ xm V[HMuD_} كD++E&+#vD@65vDjbC/D@ KPsҥK 6< pQJ6E/E&ˣ # R1L"5J|Ը&sGIry;YDt5v /l' B^!RPDj0Jj/"5D[.R(='%ha^yjl!ajNj#㯧NjrӥZ*[ @1! JB@$jK"57YQIM!8a9xǹ[#U@p55jtHM-"HMƤ*- 5Z$5n0<2[jb<6^ Rq[+xkʔ)6=8Fz6UmO}7N{<橧2B -d!BSdqYy!  R~V]) >{"8HƱaNdR /Z~~={8@"/G,W]J# B@ƻ`oEj$i RUl|6ež xXt͹k!,^C)Ԕ#@TDj"UωW{"5'VX|6]o߾yWoFB`ƌkR"4ӦMS`$}HD5! "! R HǸ=Iu&өS'ft5"ծ];ErʀH+ϙ3.6nDT2)\WEjQ!H$" R#AH4Pur>3/0-ZH5R7c9 bHC5uC&{c ZB@FԔZ  4nئ-=ÜtIkDDfذa6"C&믿6K/t[^;MTO@TM"5U^)RSAt5.3e,t2# CJѣ͚kizj6OHMD%! RSl%,H& PZ؃s-c{aHW{W͆nj壏>2M';m4/䒥uJߪ*"5U_/5HMqDIOC@&^yxO7#F0n[M˖-k6id3?~XݪT !PHM hdDj"C9pԯVXf׷j+t2=zM'8qMS:Y!REj"(ڈf?"5JbҤIj==a ±ʐ[la 3d]t1kv%wT*^/jHMjY.ڵ!2N+S_Djws!P Dj*r!RAaEjr8h 7cc*Q_Djws!P Dj*r!RAaEjr8h 7y6 /"5;"5@9 尉"59iҤ9sfµ-V$*4ƈԧ<{fĈ[n zO47xi߾}}SǽWׅ@yxHMF)mߘB -DjBV !"5H! Dj@Uu |" RqS@^HNEjt}vo F@_i# R69_&'flRj",b PoDj{{!6"5i#Ejr2P9kHM,ԤlΪـB gljHMZw"5=J?H@4I-RQSEjr4Xj O1` D%P` "$EP0"Ĝ@E `FAŌ9>y;h}bSuVU]sHMVB ԤхDjD@&&@RIJN<EjRVu @>' [&A SEjXy4UnIbT!@Djh0Y&O] R@J! HMNH"5 YYO>nƌnҥnڴi@nk<8k΍1Bvnwzr 6SHJMӉB@䀀HM 4rXQR3ewaW^}n 7tkf'p .tfͲA?*"5M' !#"59UꇉԔ g~!5s첋kѢEq֨B@Hg )Hp`! "" RR=\TWJjڴi5j.хX!_՝{9#%R3T:P RSJR\) ]͚5-EM#p-u]5o<'cEjrI !PADj*\&RSj+D!5իpY!@N#=6"59A "5N)|r%5s̱dXRB 9?n9,RL:H " RSAJ4R[x'WRӴiSK{pY!@N|wm۶-xr!B@TJWJԔjg.z_ݭ1^!P.kݺ4iRNJԔ BTR:UV3>sɕt|1\!3(OsɕԬ*jB@$Y&yk+@IjVUW]mX9`_/Gv_|]vlfxgGyvjڵ曻A5jd}O8ѵjn7pofmk5ka[&ow7_"5X[!RS%(Ir%5q~+Ҝ_]}sqÆ 4qܹSNq r[nڵ{Q馛ʔ޽{Svڹ뮻_*[wšk/~Yn9Րvm"x3fA-Xl;s]C?y睭T .gcZ\܋/K~ww8P5L[o=7tP+g+{lu嗻^{5l~n 7\uQ[n=Up;찃#b{/N]e]faU3~x9gw뭷~7n9a6BP,p!22X8ڄA  BgW^n=0ӷo_SC2l{gmNʳ>vqG//. [HkrǺV[/oƝ~Fr裏vmڴ1^9ѫ[0xB5h 6u]7dgvc 4,| IDATGq >LG>[ H"kDqY !ҍHM׿l"5@&ϟooqyc> osF(pQ pԇ bo9g„  6078(-=zر8BPXppqQ~9r9~@]`oٳ~r\p)tP[gu?`dGń!̪m۶X(S@@P@ /Ϸhw`ù\p Fw_Pƍ;pPlQ6P_ qiӦ1D]AUB[#,p/s֔P^zB:PT(,1{;%ƓfH#`_\+\ ;4W\HM\VBvt# R\IM\EdeҤIXA;J jN:$B"I#."9o8~4*Xkɢmq#)3e"FT h_c58؄OOPh}l} K'|E.~2&}A@h't9 a/T"*o#@Ij0o<#T`)|#Đyť)Y\VBvt# Rd l Eg3L83P!~hjBp ߂p8*!^888ㄐ=3^`{Ex&B' ꄃf#* P]#((&/TH :9{api DCTp.sˠľ B"e_L&("ajԨa$ ' ~lO&1懲9 Oh>6xp Jd 8(A`Dp bc=}O-Jvm~wbB!x-r!@?7$Y>3mwQT=l쭷2bZP>z<$[>ۨ}DEL !DjjI%\IMBgG(8+j884*;p~ fya6wICH  8[oufoU` !T1/v~ChajՖÓqVյS:GEEL !DjjI%R 5 Y& "DE_ !R0Djt!\IM)J RZME$]Z.RSpC WRgZH! G@HG!Djq"FI2%H/dpP/ BعzDjR \IM\@d#MoeS}ٖYl^qj>55[k-+(::vXױcǜ!3Yَ<ȬrLNA"5%H0"5 ^4]*T_\IMs5@ !5[S=Q( ok?K&3D([8A!lQB1(JEjRH駟nubQ2x`K/Yɭʊ=9N3)N+qiY{R= T BR {ݤIum.BAC M6hju\vew7Bj`3+C EY째%S5RcYJnVٓ}Jʰ>a`J͡jJ)pI 6p bC}R/\QHLALC(+pJP05+@@F! R !JjҐ"{)W\q9lٲ{aC 1RԹsg  B˜= 5F~>0-Z;Ί]R:͞={9l(| `w}3f̰⡳fr͛7"鏁\F/X*! Djt R~FQGj (OwyǪ嗶rt>G4@pi^H'xݺumm֔UӦMM lou{)7B*T B~j F$5(2>D?cu1vq9( Klzm}C="릛nr2{lG5UvAYxWN,m}qό@AjzS q) #@~ \HM6l',E({HMyy"Z% (U՜@IޚbNs%5i~FE7CXc5,d,fؠp.* $=0}1R"qǚNHdСr3{uH f֭[Ge/7"Ř ,0 e %BR/3q!vH!cǗk-745(p q̏?h3H d23!za̛7p{a؁'5@b)U՜@IޚbNs%5i~# lاrw@,㠳9gAA t(9-L-ҙ%~fwHC0 j ]4@jPH@ž ٣Bщ'h]PH-{Aq.dP>oҙ:m:ʄA8>ح[7;~ ӦM+3= (Eؐ bBDT~V+y d! Rʛ"5y6,8yϥd@ Ȏl|)}Gg$<R~`̼nmq2#P7Ԧ(P87^B7T^k}CO l5jZc\&{T昴+Q@VL D@45Dj"Cr@ WRg9C@"Ih!PrԔܒVlB"5Mg\IMg?u"Je?+܄@rIZR›EjRxP-*bHM+)F3`9VZy_|"E{xN^F&WB DjrA)LjԤ`0\IMQ "d"r>ٲΝk))IK/4CO1 5#5B ixR1kQD F=?x9眲?O8ѲבnĈV.1e?J4՗E@ȕy"5%1N\I uGkEՂKԐ _Ԃ!Z1C(t!%v5CmHkU5Rd.٤&5uFbMj(#@e z%tHM -f+)d/jZ7D$05cC c]Μ9wqM: SyKܱcG+8ko߾ yRヨDno):I%ʧv_ZV0M4*ԗ[Le^96ثW/CM05o޼ OA(]"H.| +)d33n87as qvq0q)A]$N?dgG+0G}Z'88ɾVs9jPl_u;9g*j՜~VqqqbBNۥKZ1LzN k2go!kz Ra^F?xyCԘA/PB(j||%p /ny}B!={_m$O>q7x9#8l0w! SvB3EN wѮ]w֌ 5y۳gO( ؠkGAUǵnE\{M B<DjC(%IBxq$5@CnޚCPxϛxIg88vGBC?|Uǹ& ?ClpqFq9;[}>쯀;XC=\{#OCqA:cM/+Px_n]#28իWw(V.ԍ?ߎ\m\6l8;zQrʔ)P3s{N a! DivC q+)fA*@(`*$eѢE+(H8{5 R̈́orIB받P<0gK7oaj8 c0BǛBXp3µx_~}Go#B47o!=Ɯ PSdsq(3G͟?ߜuG)` D!;"C%'aWd1#$ )8l H/*C" Ǿ Z+Y( 0LTybL5%ƐOkрxJƞ(AHXep |H"J믿nزv!Bxnᆂe?Kك]1E@& ShDj x:K h <[  Gv+RjQljũf>_4_wOH !Mk] Ax+#Msp8bT ~=#z…L Ă@쳁N@ju# "c86ң:ʰQw s}7m'$9@Ƥ{#n b³ B6EvV(48pб =)7ƀĆa|<\b}ek$ilqL(YBr劐Jeƅ)Q@!PB@HMy"5)YO3WRSg@TB~*Pbha| 7+ñT0/ HIf鞬^84BP' _d \ XdZzEֳOCjB@| RTاHM-&JjKP޾LW& !~RgdA&u&, HMA`"5]$[++TB .:g"5q\=$JYJDR,JjA\R ! 3\HM.(! *HME+DjJlAc2\I B]5! o2T@&9*K@If<.]( IK0 !cY(K%#@E(r%vHM-hL`'|b׃c2!@<ҁB@T VԔNQI V/,eH#OZ&2d:AD)-*Jj[SN׵kWGo_Ihd!rHLSF@]޽ݕW^.rbDjR("59 C$agHM֬-^gulS9!K ._IbT!@Djh0Y&Ot_4}5RSF ׹sg5)$DjR("59 C$agHM֬*-*SjMUDjfX$ $Vm#)Z&Ti< Rk{=0stf/HMNH"5 Y)RBs^{mJ+5X 8]tEnѢElhWUK"5[sXBDjb8 6M&W ӟ}Yw'O<1cƸK.s=qƕA&$lA@&9kWKEj oj;I/3qYhtf/HMNH"5 YI'aVܪF裏v\t#QGn4{! HMޠMV"5Z/Y+/IZ-*H&"5\J[M@VܠA\vw:F?|7~xפIJ\s޽B@!7Djm;Y[yݪϟ[o=xbND<5! B@!DjJyJ߼=]tiѣҥKFUB@! BjZ<*͒%K8^ B v(,vK"%HM-i 4ѰB@䆀䆓B@# RSqJLH)%D@r! @I" RSmR5W_}#DD{ia(B@! RS9JlqnĈ4JI IDAT5! B@!$*Mj|M7sLWV-7u$_ #p~K4! @oտX1UT<îe˖V^z~OM#Kz˽/xy䑅7G߬h hx" %w!_*Dj.RG'AC-Z }agϞnnr't;SVhi~9rN;{6 :؋oyP^{mw.7os7tNu]o8% (yYEKdRZjEE !.{Я_ԡc=ȏ?y{6d“ڵkO>-\H mwO=(ޮy桤пA-Ν;|Fj}޼ynKͺԤfc=Q/^' ~B@$B9wG$ڪ3{u/{l/0l~7߸YfM5\Bk{1׸qcwq99s渳:˔c9vm wnn7}H Θ1c?ܝ|koC"+O>Ǝkwݻ[W\qO.uf #|!vӧO ϪVOC@KpYBXTDjVYexbk5%K,,6^{~B2I FHN#?z_zk(Ԡ^@@ +; 6jNp;S)D&`S%$1 c$ 6ƫkAN/_һyLjN=TwؗA8~{r,,Xb? !Za=~8}QשSH :BѾ{ gQ&iӦpo0>Wg'`` Kq!;hHfJg3m bDa Ut8zw,Cj ;(U,RSH5V&i{ @7gR/RD4!r䣡6[V[mtMO+̍\2Ut\傒i} O+ @W+@P(V! T(HMAD"P\K5BjjToBDEj 2++TV`(eY 6hP(PY /n_I&FF>RnQԑͨseY(G!Ҭ5*5UMĹs5jԈݵFXjqP5.Jkv{n^M"= 6?y/.Gy(fQ:+LL~͚5ephO7{lwa9e6 N:5t(*LO?2d_KKV)NE'B]$'{t?#5|{O[AEm@dk]d~-ʅc¾#yY7Q-D]'ڞ[>c8 ! 'm=Q$E给$ ~Ŏ̗6={󽲁1gסC7sLG/"x͛gS^s1'Xti؃/F =| ,s̽^zfJ QP ~&s%o{A2(nf~-̷,dxUQT.ԠʍEWRE9tP#;@¥v>SܬxÈ'5=zܬ(=8\ nTnHO(XÆ s< (K} H 77QWfT@ƻ[ >B u\ߞyÁ!6^ac1YpfE3ΰ,d Răc= C1W'(C B9묳p26>KBU>ԗB$I g{ʐN:ag<,!/ߑ|z_݌q;z^⣄}9=Ņ>'j߾r ~ h۶|F /E26|Clއh{qo*{o%b[Ec _{k{L89|6P("7|>O?d$ BJ0ߒmIlQT 93͑7'{RMCͅ* O{衇&lb5/([66CQnƲ@`r.`aOH4ޤ @B~c ua@8_x2ۃ=5=nFz熜>}_{6E7M}y6cR=ďrKiy@̍:g&ȶ?8y |e--C!sE@g]4X_ϲ] |O0w-X w$w*$0`z)~ G]w|?Cr^n77^rB [^p Baj(2|M(qpOޠ`7$l,x0.dWH a섊oB{Gs|5B¾yngH-Rce7߃Oi`/JFl wx?܀Qzu{i AB®KQ63 ~'5a;pDS+g7&[JL9ӫ~P++/QH !MwbbA&d b;԰udRCu'?aSi@"Dx2tA Qxޢ07F"d{>~pGbYt!v(0{Ziپ#|,qMs !$ ',HjG~? gD oB &Lp[a,_5*5&%rB Y>"i9@Ŝ?{ _!5da%V[7u& Mřg C`(66dT~ >ޜVLT -HA7*:nrWS2DF I@(P r7 (I$ůFi"P!;)ϓGALY3>giA>hKd4>;ꨣ,c|[ /AfgeZ=aruY]wFā9#DX_ꅜB@!P/_*Lr&B$5JĩXC"9NԩS-sSA?(شiS2n8ÇQ8 ZqF )I_ şȡN5Xh~F;ƂLDCFjƘ2( CA4(De[s hAH sN2rSؒtmH !@6 [4Gyo~x'$o߾ )'C Kl2bN`zBjZj:uddBA& 8/8uհkذX'$7B(J!'B ̝6l r\ 6acP B(Bڥ҃ s,X0Pw]"EK;0HRcHVOjpxgϞmNSO=e7hoi((yC_>}8OШCҐIj2FBz07g@q@Jpi$58* ! '$y07| }PH3(%!l>.C y;"Xgs IYiAȾKs!/CN'Jz!|!/Jc2Gl^ i~(S|i0gF(!I,a  C Daz#sJڢ0^![B!XB@|#P_"EK;>HRTB` ^呚@H!A =XsqQpٯ d̙bv)qY' 8ЌIj R #7AQjԨSa;$ \Ņ-^TC=?Ep;gb>,`#=0~櫯3T!|PuC4F(j3Sy{Rm>=(kk{i1 _e+ru4TA STq2ϕ"EK(oIMypv oyoaT gFo}M|B,YMa&H1F&7ŸVTYPfP zA@koaXc? Up_|)O тDp,GP`khEOʆ-z\[i 5lDy(V/J}ddnMjx~Eu!@(&A`" @MqR/55! @_wȢx"M4!A6uAaoJ7PjԄ@)"PלH/q|IGC hR -ҋ@BzQ̅(E+U՜҂@W&-WEIx!yb?T)@P =hӖ IDATt-$ /ˮVI oBX,c'a2,POQ db=Y|'ѨuR(b&iZ1d!l]tsUe~ad" HZ@42ʱgeod+{]vVo4$o`Rf(>HO Ff6R^3(|˚1Q 0_A!dR#EQ%5d:N1 7h /O|<hC:amf&0Rr 7:wĖ0oawv{饗,XUtu$E"eٲ,{yGy(PB@"!/z)E71'"!YoҤ =@#N73wescjP򪫮r{k?Q$PP.͛g5VP6(҂"B*h C0E2I勺Ѿ}{+d QS2PAuN: 6^:'+;v*֬Y3SjA R~N 0C i AP[=-bj;Τ&5לi)CqJ"=N-"]vkݺ$5v,}ΚZ9` *5Ie E8Y`נPAXS)T{ oװFdS ΐB(BۦD?/7_[fCKw~71GEՠ0fǎ!&̙3ݵ^ke9zT6l"N;!8hsBx( `\8VB=d"8Ч~E5ć0-6FWw `xG5F !oAPp)I=zlEol8.|)i}c9]s5_n$ PS`=!1p4@ Ga\`LRaǂ-e(B8ևF4AXs PxИ;A Luf!r,a}(Jow4o="CjGVoB@"P//"gC71gЩ?HN7% 'L`ukpQLp2B;֜W¯|z9$}ƍ9g oiKҳgO##{Ϝb7|ӜgԛA_ReqP;o6o_A3j}>8B'JF2  95OPg}Y׮j :D7Riq ^A/X&cYwJ-8ɓO>i'7i()\CoT;&{~amۚruPP 4B _'AF(obH oQBPJpQiM98|AE`qH}ѢE[tޮHb Bpt Q&*Cc6-KӧTǑg  4 g;Qqk#+;6bḷW^V ;-d8FZDcos vsO&T B۩ ɴul/m…BYڵMFj ;RQKv)ԠଷzNaq@!IxúN4ɮ5s--Cжi(OW7/QDTTC \pTP,[ 2e"Bd0"°^ 1CAe $傰,`5j0egΜ9FNXֽkxCؘ /ЈNNXwmo8µPoo!te:T+ A{ߐ֭kskm}@DPAa{ qDA!hafgy@Ԧcln\3+Ԥ`kCAuBCbH߼9{l#m(V\GH jG',-Cжi(O&:]M : 8(DX/!D~>o!%8LzbHO- 𖝐%­ z}6+Q@iаĹ CGA_3߈s Ut}&s BDrH oK8l1gsZD:lDm % ?*If (7O \@P Mq`iS:CŽt1` J   5l/@Z!c`\7Y]Ey(6''|/-I!%>Rô!-~϶ J 7§H9[+}3\p8?~=!FAG?%Q+Bra*A Ff c}H[\?5X#B\{ za$4(O}! @_r%T_ܿ!5iyO^6)x 1_<҃f*@HϗY+:]srd\~&=V1]ft<>W/"]@7gR>6ګ !<t&odUUz@ 3!1&@r u7 *!&H_Ҷo əPZUB 9nnȑ1Z ! /UF 32d <% '@e>tsD@K"MF əԀ-I+ o! ofa),&H+_ҺwRPpذaGM*F[ ;̊6m4UdAzB"K$RTf)C2CA^zl暮B`y@KdRW\q={8qYf2B % ٶh,Фd5M! rB@KN0 !P*T0ܵluիWnjB@y֬YnĈPYA# !$`dbjJ¤ƣ=tPsZkSf!4Q!<@?ۋwݝuYq0K6! bX/KUTԤTLTUB@! Juq[mՒ?!͠RT 9YtR3B@!4i&O9nDjҽeх B@$ |mˏ"55q$ndB@"UԚ^ {AH.! B )U5IY)&&w, B@!j2U5hE@FWB@!J}$`~lR\׫HML ! HwW\qԩ6lXY<5چ:w:\4aDjt"5B@! %i+?{EjmzC!Q%cB@5@@F]B@! @"KD.[^ ʻZr rڵ+ ?5j;EԄB@!WDj2KfĚ5kW^٭nns/vcc B@! @"5.<"5)w.]ZB]=\.]R.B@$$Ral) αfɒ%eըQcp&B@_eԤZ5RiR~1hB@! HM,ܤt4IY1)B@HHZsWkF{it=! A@&1KwCEjq gFASB@! @I*J7|͜9ժUM:0k!  THͥ^>#7qD+ި&@|P-eC>}c,B@!PT1 .2TV?ӖBKC~JB@T.{#BnvC A! !hs;#`LB@! hxёH*/^R"4B`ɒ%n׶VM! @! RSj+ZLj|gR&A*S@uI! @)"R\ՊIb,!(OrX! }T I"kB@}]B@!PWbsJ?_W <_Y+RG)L`siy`@ot? jMUo8m۶u|?|׸qc#CoHn f?eyBC%> ^BsM7YMGHQ3gt-[4{j+wn;W_}X3~!KY 2ϓp@r*ַrY-C c L 5wQ *iMB@\[.(T7'?4Udܸq2B{衇&l⮿z#588@{mKڵ-Tiv.j6D R *iWD)‘gaU؋ꪫMիӧ)C8]w}D_ ,*s=MMijeW5\~>/bIqj-]ըQH d(xƆybվ}{#PM6].=5gɎ41'Oj 8%628Bm`:<Ӧpdάgs5bOڊ^! }R:I!4 gg7BC]6mY@jPVPYPMPXOn0*΃LU P()8Դ OHA0QN9!\88%ɜBA^{mSP.p:,siOAE!'3'U )$GJ@> r]v%TI&A~yfG}FiPdM2 ARh[u8q ?#a}(nBTj 1Aj ?#' #5~O k$Rf)G@&kT( SAjPLP=pxQ"P& &-Z0ҁS YA@! $5,d^u# 8!¡n; bJP6R*"Z@ xA@UB '(ƞBؓ z@ ?YM`|b~CXka8I aWa` ԬlrF(gk|VAR%| \=3A_2"I5 ɴAP##B "5*SAjPcxv60P/}LCR؀+\|~ v 턈vECaPWH\bS[ؾ d 5G8C(/{w/ \ ҙD_|9 ¾)bPr}F(UAւ5( P`Yf;nCcxmǗa?< `$3{w1bH(}'x?{\ 6Ԕ#PsB@$# RիZSAj2Y+> K냂z0L W~H)dJ ZO ŹՑmB@!PQVQJ2%5_|ՎkxJC!Ɲv 1R(l۶988%ā%o w6KϞ=ٳS:*Do8ێDVⓧ~ا}0<IsC?ΫSQYM!||>@I8:=PS6svT_z)s9"ܔ)SLE|@,h%~BE8D AjPZ COλCm;v^wu0W yZjOH$su)2)ɼ /wu5")^裏ÆѣGc(Z/^lBVWTI1*֕=Eݐ%pEb-l ITո|kРc=bws C?S+%@hn$5]災4{98 /9p֭[{Pbh88ἉǑ[m>!ha $ 5"{F 8Zwyv… MU4>@pqQ 18'ǘFbywXC9"F1H&Ml<⃳mb|A -*#Nlb/AGUsv؁҄J:UVՖ[nikȺzՅϼ.A/u%1 # \Yg"5^T'#U˭ "KA`%Ij /]XT HZpz-stq;Y؋ Rwơfo KyHc Y6?q APh)7`Sn?ʂbfd裏vs)8dcS= >I_G= 2>`3sC AAb}7{ρ Xl1{YP]P;0쏁<0g2A`mܹF>X'ǡJ?A(k0~evq|`d=4@a0a|Ah* Mܛq_ҶO_if'/D?^c$b}&D`cL^ –;ą- `O 84IPh&2Ma6qp6s6v-lg̊N>7:{xG9ʴ=8%r6|!4,827!z1lvs}@ fe$(R,>Zܢ](hتF@_U#kZ'ǔIhP, &(.( g%`/Lf (}X!PH~+UzLգE@] #5Wwoz nE![( ~RV4WY 8 KV!6lY<B uHҺ?z㤌 RбT>CαC鼪@@_U>C@]<\ d#)CV/z/UF6Q&J#!E2cH8D)LC:$6=jcYKD =_"ųG&ؓ>8l۷}D:u2R޶;Dvm7S>2*תdP͚5mVelq ч@YcdC@ן ! 򅀞/B6y&fDPJl8p9"}={}Ɔ޸T`CE8묳 I:rFmjƌp)ILԊ 7Њ2Rҥ 8!d2R =ڊI2Ff:fw|VEaA4FMX$sf4juQVI@-oي\QN9ea4if.M RV_veV:2>%x|С8qga oի~K+MBΫ=ofRB& cUsF]z5j[mլ(⚺<Zu**r2ԽaB!CC?^tw5s!o|7?5dȐ!xCVѣ͛/S7F##c Q6}&L`L׌ID'*J}N<يX g-[tƍtġgZ2۶m[fq(@6mj 6 XEݺ;o*Ӥ)Ѥh"24+$%!d)LDdLѠRBG*MWm}=3}^gw6vgX(䵡P#’2Qƅ=QąI}݉΋/A(3q/|k5HtU-2ѧgeoO׆n+RGaj'%ۤXeOEx&Iζ!5Hs:p) ceR8-oIDwEr[s߄++sNgUK=I \\NxҮ\2D#tnn;3&BK.d)c['O B琿3@.V[։RsxK3 aWEȲ@ !_j;l-P%{d9|jij!ٍrqF ~8{šrJB$]-Kgt.m4UAt V~b= ,) x7~ascg<Uw2ڙ^;,lsr2cCՆB 4m7c= ƍ@ȗq@sjR%@C oڛ4mD=?Pʞ3T` D KM&I#D !}c6I-cF34 /qgR@u>(=ǻ@.Np}u]Wt 0 BQM </1)2=mF/7EE  O|"]:(8c;ف@ȗ1νgRsq?OS(@ \K;C{-(B阨B Kch+l]Gws@ 0曯8Cxe ?BB ˰m_I.-Rs9ż۾^G F@zW Ah&xԵP:4Z@]|ix } _vm^Xn >G #fW6|I'T{r6a7vA @ 0,B 7]=2g,.-ZZw}ɰo}{MDBQ>@ K3ơ 1iB' 3G 1@/13@`X|7HMl(-0X@ ("KL@ !_l RӾ1JC( ֨4!"mYAjZ6`jna!@ȗ@ 0,B }c6P Qi &@  _n˪RӲVsC( ٨7Ba!eXȶ 58P`J@ <51@`2Dp[Vu ذBaXF@ %@  /B}iߘ !kTᩉ9CD !۲Դl B6 /1@`X|7HMl(-0X@ OḾ@ " ܖUe6~_/6`c-6|9O>bw.:կ~^,F Y<@`|2->HMkMwy=aţ{)Zh/<_,E Y:@`|-yE 08s#Qx^{5WF@ 0 czt9!_Ft ^4&oG>s@`v Je 0Bf3HM3ed*{kK32EB Y5@`|)܍~YFh5 @`!Jlo 0:B릿)HMGh:(ҌxE 0cx7!_Fu#4l뭷.N:BZ@ @(D3 2!_b>dfLj>]w]q 7}cK/4 "ꪫEgvۇ/7jC t@_-_&5__?%j\z饋e]6D #p Ds-^׍!zcȗ@ @*@AʗHnVq9眓7F  z뭗 GqDscKBT</c=^f*_j QCJTB@ڎ;XZrh|Qq 0B ֨4"_j?!g1 xmKȗP//1#@`X#_z&5?->a? ! wXr%vaʐ/1j F@ȗQ# fuKϤf-,VYeb-=hFO @@W^Y|oloB4vha| B|O}*mEpV[mI5|iD3$@]3YmՊo|jwCDDՁ@hmzZ?~{&5yC?ClvTBָD$ M>B R3Qz!PG(Mo80wmAk8G;q Pg;#PG(iuMo80wmAk8G;q Pg5읿oӞkxFG?z([kV0֨ѕxt,ooWu"Ō-!7FLVg|k_Kr-Wbv+7׼5'\lfw?ag=+}xc[Ş{Y\}SM7ݴ8C}{s~7/| .K,K5KqAyIOzRpw~LJ? .`qoۋᄏӟT,<t}*Vb-ŧ?b5,~_[ouq ,@z6lS)k|= K "?3w+'xb巿miO?m(I ?9CvGe׿^~jܗZj)e/Yd o,gReNh}ݗ31#f~ۯA}ϼ$^ΩhZ z_S\x{&}-~bUW%6YyIFDߦ-iPȍ̶m y^V>_ ~,uߋ^: a袋[/)WD(׿>;)\sMz摏|drʼ^{mz~Pmo~BWU# UR8n+\'g>$:kt",O~r1 e 7ܐїW_=Ϳ7x=/;)f񶷽-o-1#<8s<)!x:Ba"&wN2ַ5lxEZ{nW{{>6.J8&Ah̭ԜzZ[*%S@b&$=°'%guVR)|;"a8j?O7`[oMVZ)| W\1IQJ9_aϤ!{\ނũ_~rSLi!Xgyf!)ћt "4{)LZo^ls['[;\xw$^r%W\12򖷼xc< <]eY8C^{sG>Hg;Nj;Ғ(E~{dG IDATtC.O~22EN2ݸ `>9_}W} CGm89l;3BuŒ_GMo_]WS&#ʿvy$o$/^xyMƚsK(_WSN9%=o{>Cc9f\f 7a 1ƐdvmW7sQ)ﯰ L뮻&llYY"I\l6f Ik wuM4P" hsOR컽<\0E !vncBt8R((?K/4IPP &'1* ʐzBe|J?):BT"B2w2i_ScHBF[nI;IqtBQ{_H\.cMsJ}[gu*SP3^7 S)+rd@i'Ȕ}CiS>ΒYgXq32(k1  'dB#@,Hz=cM<2vH.9m}[3Ȃu/|$y@:~a^~llB^x#R]dVm #֖xC7ޡ> q xrl RS=~޾AȈQv9)7*cou"GN% y 1wYc!5csHgh͏EȕH)‚(#UŒw'¦g~iCnlg>-"M=#) Xs#U2mP|R lJO ")6t6a\|C&(6r6/@{ip 3dnT}% :E Rg^#4!!ޏ`RV(RtviO³qtIz?Fa66Ŋ[.Lzg\d$97ոP&2\+sUwRx,jH:Ba /YIW;l$לUI7?(lȍǀC>ݚ+< 3؜X)B&<wMHʬo `cRGAeAtSg}ad$mUb.JȬ2$4uzfSdm/rRO;Y 6a'=H~zoL[E (]PV33,e?RC07{M%bRtNo DҤ^8}tɁ>"SxY%e2H 3oyU&ҙ@R}j\rVQEdRV):LԾA#F'oiX<JvO 9<ϯ]kL1y=׍TjżIW| d8qO33["5Ó L%.r\ʉ0VE;A6=YmK"5p.lWC 5|P'ah&{pxdߧ#5!73;#'5' +"bb0yXŦA,S4fiGXl:Ȋ:m^6tD*&aQdYg*ؼERaXoXy <+ E0><+iV;PF 9K cB)^{XoMw^Baʖ"x$Ĭ#ʄsղǢ޹C,Δ,c= a5ʉB B$ }2~\PJ~"aE7T#,sVp1w!  zgz޾:}Ƴz5 P4@D$G"ʜ9XbU$ +ŏ  ܈YT-֣:xprɤ&N2B(Y<ጛb3z'_E;l$#{b*m2"lYV Vqsnmz!3QgLrCF7ٓ G $ʄXDȍa̮uYc!56 Dj-55BlߧNѦ0km&0ٕ:ChQmZuB`0 +Ds ,&3;3-=il *:^5Q;K)tXfbL0Lȓ~ /! po)ʰF v93>yǰSGRMINo71;&Mu=#h]]! gbz_BjڲQ8kVSFQ#Q|(4Zپq&@-4}6}mhg 0߉%5g6!GۆW~L\ʿ^˨HMϽm7̹Vn RS=_G(M}EpP ԸaB#o#f^K ]K&5I#}&k{Y,ݪk EcqsfeDN[}*x|>!_Q[*RZ)ڏtom`%x-9JDy(CPI)08zvK\wW(n͕.Bˑ QzTW\9+ |,:vzj$yQoԳ<0D|?ؔ%Kؘ˞O•֗]vY S?oƎ;8k%|)nHP/Lj\*4 ڨH:\5-$?h饗Ny+$5r^Fv,WRt\~I/~1]!˺Wt3Ygm}YӔ]k\Q$a-{j~!xp) s,38#mnRy ykq5}U5(a䚼W<p@ 9ߋz;e"F''O2ͻ;찴 kg=f%υ>;}mTɽ95Af(2ZUy {^愡rYs547\3oQ{Gb^2Y"u",R[ur9F~kKGپ/!_BJm !;@%T<#<ДIBɑlO=X I(zKԯrwkyP5q:Baeek<֚6E5Wʬ"qdܚD<5E1hs{,dB͍XR8X:QrL²L#vx?%^',#]{$$_~dhA}3{C[C˺/ sc(/|<1ẍ́Ԑ]H\;xgYdJ0w'd#&}Zۣ\a R]֙Bt/z֐PPHexSHBބHP6T?xS2'KXr吕QaqI񡴨[aAXXlW]u\겡!YkNR-ܒt{ dqsQ䄅p*R3\CYaSۈyK` J&.Jw_ȺA"}mI AB`Di e#M R_9:6ϒB8JVٚ-X*jpqG4YFQϸ#9ޚ;GuTLMgz Wsq|-OVO:}yM2u'd 3g(Y!(-\_ ١lĨ"5-e+-d1#Fn RtIjϚfBjXAf_F*y䑉X$|'R{i G"cLGTa;\Q/IMȗzr K?+ߩ~[CjX0m0Tʴ͚%,QpSypra9ҍ԰\X<>$5(6q9t(XeO eD( w}uxsX!|x*RXiy%qR@d|)+GIѳȲJv霮>{NX\Ljx(T6 o k>Wqa PDB@a邿b˛K&OU}le۸q:BaeXy h&5Uڦ@ۨ Ч32_Y}5IڭF .A&d0C^+ $K7RÓ@dO 3Yek;HޑE%BvÄ+vu{UۙrY5@! 0p6gD/Q7:rUXooXk{뷟>}!_KjBMjBr'Pb8BMYc5Djl}wV2VnG5`R,P|odi{ŇeMuK} o (x:dOM%d7;(]tdC3|Ԅ|y^&!_RO eg@# Ȇs'ƜC, 0 ,PIj0 !@x(wuWwq/1cPu^dt{_s\uQ/IMYg 2E$!_:5&C@qf9FZ8UIl^>[ FI}m݊ {y@v$T>tKiNRX!UU{1B1R6 ^ڈxw0Se>6AAraGzg߭n>1S_PA=;X  ʷuvv˚*h<|AQ~rhgY.eLΦj7y8YU7N{X3cH'xE>7Օ{ݰ QߺuBCz:Ros#f(^!7 fԛz4}udR#\wJo7i!3zB 5{Ɔ _8I/Mԛ޾Iѿ#pI+M_Moߤ͇f. oMoߘ-^ߦ Ƅ@JOv?Σ=_8FLf{)3~ܭ#ƁO7lLF1sDO%;,zݾL"WtmB' M_MoߠB/ E^vph[ ڻ\JRo|n/!` U&{V~!Ew^:vMA6(8Tj4So-[.xDܮ֨r^73uL޾~Tz&tkXɭcX8zt!P=no؍h3mTE޹~Mn.Ehif7ͦ3Wms͍zNTNPUoN*WWS{.Lr$͓Ii9]i%][/2qvuDrƕɼ(nVd2>e~ j(¡MmZwFjr^R J|^ (#9T]$e hd:^jm~Sc'U+N15kG\ _7lK!֗\rIz\$5OzINkM[wkNb!ϬUkOh߭e: =#L{w^lE&#N8ᄤdyc&O3i;y#"=F8Yd3(/wnWIA &]%s`!uC9xڴ#݋&؞yx׺ϸҕՃê ĎgCL., ?1QmN)k#;eRc }dz %!+"#$,j [o%\,W&d&BCĒhYp裿 \'D6GB{W_=%AėZ|H!DDsəa µ8) 8o&5\D]ڃT|#ȵB%8b-%$R]cWFV"K?s LwߤI Z3l-h S$^J ya][dT/k1]>c矟օ݊c|0()9 !/G&l|r/;d VYeT79EAQ+&/LjQ} @62|6@3d r(W`+(.wŸ쩒) kK}^Njk)JXLov:JZѽ榷0#ַul湆3t̯l^}.eRc 5{җ4#l^h]3d R:{:Q^gVL.'{"ˬ^% fKo ";;IӁƪy 3}W%srBNKr72\;#Ճ!otGNIG'Jz JVuQX} b<6Q G o) ȱu635g6Hւ(5!q,Iҭ Ijx:CLca)@)9KEX%9dpZ̛uP,($,?ee7LVRm#If<X^^CH2'*_%fjK^A"D YpNz ,˄N` 2ymڝa!x_]PկEdi$5_ܣXQ 5}ɾ!]sμR)6|vo5)߲"NnZk6OJ3y8D/Yiw#521⎐I>צKq,P^g԰ zrDe #96첡t@yXTJۚ.kGAnrzл׻?͑2匬~.p/Sy9JTd vv9s"z`OO$V2ZaOe+2-( rXWw/X (-&-Ϥ&;rXLܖӑ\&AXd2:ѭݝP\TܷL؄r|.(gFjXk|с! Ϙv:$IjaR5ɲI ag!O. d[-5?Qy)R}Q5]tNR#dcXRܑku 5@߰t}"S}o9FPS>v,Es.NRʲNI1&s3릔;і2!߲L lQC0fRS%+DZyg;R"4ƆeB=5ۘY :͚esӷ1u\0eRCyX&l &HMv$ 뺍rRI +vo?< ,UHMyBS6(Qp$\(az!gY2a! °²&b)'JS`, >g+[4'0G%c= e͆ŋE'{j2"ܺ;E|^Bss^7RCpu{ZC!9G;7bhlxf5QQ9R ]@E [.A85+|cF(xq~16묉~h5kJuICR)k&$tz/Cj(gd ,^*Yt;w1^QY͚EӿZ&bn-1,ˤ"͸!:1;op oʻaOR;0y {5#uNt |!m09o$[t!AgnțԅH!LU!5S7Jj->o2g+YXΊWPwZ&*~RܵDD@ܢP%YVCD}¤,vs;۰hY]9aDA1f*dl|B#>!<;C Ʊlե`~lÂ,tkw^0GBk1^K08|3KR ( yhkւsmLI3J/kUB1wDAm朹CYA)$滵ij a-y<5bM[߬#_n'r¢B1IukJAQ9a7d7^asHM7b3 Q:=5dosԐdx,a&Őq ^/X1y(xj" lKoK/Lj`l3p{y~u^l\.w7;M_MoLǖaǴ$UdDt P t F:N>O kM%#-2sog Lxsqssy?U6F^L9Yc#5#ql9n\yrD @cXmަoLN4].1w ~ +h(u8~")LD:Mbphmz;:#KXQQQ#PgLj'J ߦ}#-F@o77C԰$Z^fvR>BѾ@`nBČa!Џ|Ej4|JW%2#1m)!_2Rَ@ȗ>W&5{rKq9;z5@ml[obVh R{ 22EC`/R .XmݶXz饋[.%yGl曋N:8s[r /C@!_bn|I ^z@jq}%/_lSȗHKҁf-@`eƤE{@!yH'@ zGG'|r?/Ɠ@ L@ȗ"e|H@/:b/ga" Y@ȗY:]&CBq@  <)@ 0(B ɨ'Hdc"!z'2#~ޚƍR4(h'!_9nluau@ 0V AxkfdF ˀL  ~6@ A e(N^Aj&oLQ\l@ =Xxᅋ?υۊn@ @ȗ/sAjz* 袋 6ؠ8M7tE$vZN;gyfk@ F Kmf̚~!@ @ę@ 0,B }c6?/Їzԣ{キxP? 5646V5h!_5^lma:zUxlv}>7  ը3Cb2,dWo@[;mN~~7wDeA HM{*Z@ C H, eoMxifd("HḾ@ @ h#Aj8jnsքf 5-49CZ0H@|i Ajj۪9C}'Ҵm 54KAޘ@ 0,B }c6(va#H!-E Hasy׿&2 |9ڢ 9#eYXo&I 0A 5[ouqUZpÁ@cx /Xi-ܲx+^1e{?N(~_&#믟’%Y;\|Chk}oq=У( nQ.#B6x㍓`|=&k@?;񎞿gDxH \p)g?Yq]wӟ矿 >kQo-:_bv-x0=Cw],B[e7{]v%E pmSHͿbyM.(@ |XYY?{UD4L8;Qc|{SX*r7g>SlFq.o/>O&$-RśbW/7'sy2?[lE},jWя~t{kv;&o駟s1r-H3S^yZkb5LkM7-uW <)YƐ/t+]LJtwťO0yqـ]w591 sN~\r%dTgr£øQG"W[S~AxsqXS5Ob94G =yOʄ=/{f:@ @ Dz&5S?LS)tMSҹ Ivat?%_e`v')dv#=AG"4Y?A"8AW7Ru]6m|#Y\)kU{nDJv7R?_>]ysƳJָ(` F}#JG@`BLj&3,'? 0/ ;nAϯs(5eٳ}c՗KVc |#{@b4m@kxC"D@0ʽP,M2HS6hjH+31S89@.jUWMIɐ5}Y}0pU(2ƻ3]ۃW D`yϐJ$C܌Dqpl>AjB'@`2[w=IbO~2qIЅ3I9< ׁKS> BA1Q(ʈoe'ȿ$Fؙ1Exzkk0-tk׺뮛¸\P4?GqDr%/nE¹(<;ڣx3&mBɁ#K",Rhd3"K?ⰿzxtӞR B٬8[{|O?atA׾>G&?剫l~֔v@ 05ƙS(B7%o00ʺ2D!ugExe]Dc>%gwiBx (PwzKB+N;%"y@E{eM!P7tSeIX^P!W{wy;3y 5|P,R黈 r0 z%b9,$%/yI"szj *F9<R\?OUɤ+B\V=x8r m.(,m:@ E3xQAQ~l {gz$ifaiS՞nQKx暚Ao^O|^yg>3/J [mU駟|;ַRUsLzv%. 'P:b2,dUo-R3(dB =ME_2N`Sv%D|$fR#ɦ(ɔ{'eG438#%X8Pw\pIF^ʅ‹PzꩉPkbW.v}Eh#,bM)|-Ȕ St 6Ho`}O<.R!ioR)|Yv!d WR/dD^앯|L_q$z{9ڎh+^#L;p\(뭷^jKng$CC_>niAc͈<@sJ{%DF$= {ރ<ˋ$Q}Ȱ11Hb3<3xcm&5~;IP3QD(rHQfc=xsޒs^W\ql/~rxPgg⪫JJ@P~)O3P) mݖ>AR׽ޛbzuצˀPt 4P]w}wR)ۼS^J4^wuW" 'ɁngrDAQϿC ?y((BU.eRk_<D҆[n%%3*zQEjͪ.s@rO혊!<@Gr)o|`45yЙ*$g+Ou][f,4g+O9&Z}\wޤ#3IjYys"&:Jtqc7M/(w#5.h_nj<µxZP@yߟ<#0=m ?Ht&LjLAI^..6(;8xcz5ל K6__!mx3BV̋n&_ &5m(qx@@jx?5n|iݐEgʗZfQYYl)¬(HH(; %džWC /R8E\ wܑ#t%' waWB[S)hBW'%{5T#5~{RSX%y %sI:E{v!B=5By_'?BA#l:RbϓF {ڋYg5L 8nU#s3BO!5@k3Kg,X!BxZ ^s5"i^郭H,zLjgAjOgz- 7Z@ h. #T[JȗTs#Џ|Ej}k9ZXk::W@hBguw~ 5< Ŕk;GQҙrJ)/.Ś23T.[DCApޥh{ 5q}B.X$!fH3܊)HϜ벂\˿K.uƒItFr("+BɴF$_$սThqWQh_UӼia %\ Rsu> cDW\s{&R_W:#3bjxҐ֦6~ƛi}Թmo  0Nkjz s#PW*E15Q9 JtEWE|+TOXT_]j”ÑXARՁP΅3" ұ馛ΩڵnLsL _|M7'jWy7A!w{#,eWf󝃮1!Ы|]~Sȗq! f@d_{ _.PlMJXRK<1ڦhg$1yL/A&\or+s9cx<}/wGuTz? %tYHn _ISA` /PG(:kKI/.* Z@e\] 2.㽁U"5p*=Jl)$C~ySvJʉ4lśG}tR/]  ))r}3 Rj !cȔL<9ڥD lr ["} EsN%'D.$(E0@dDćg6Ja6WB5:SR;D[ ~W|_NDPHCT!¬mWsOĜy)AZkU/O1A~:(a_$ +$B#W$HĚ~{[H>n8|y~6 e<#;޷7~5V6pӟ4WAlgz/ozBEϏ{7VMlU:RCy睋O>9%\#)+r& :Ҧ-& ˵naR%sk^tEYyK^<=Ȓ.ahgW3ք!6F[n?H+M/Ddx,/%T>s ,8/{´z¼!clv3WaoD A0&$Mţ̩ /L? !A /yu4cRxL.䒔L~w[`N(O Oe2 l+J/e@T,bkMW~I8|IY<, =wûwEB*a.6Dd7!=.œuY{$sBwrps/~qjv o *̺-Ju!,&!wߝBp!99~=-)<_"!E|+w-}!?Sa3 ?k|9 !6d>sBͅqA̚iq~W3w3mC{ - D[&UmP7ItiS T/DRTd&ҫ|Ej~bY Ex%Oy H&ƪiD3'/2SS>o66^MpyvY% L]ʹɛ 3Dy b]k/ \yiFꄧi>%ψ;^߻~ 5cm|cӐ*̪S^02l" E}oq 7CBxx`Gܹ#(8>UԹ=d)<7w kř;WGg?Ə3wJłP`xnДO;ͤwʽS;3w͇S(gV'm!!'^}^THςPB/,ހsk"Ug\ B < g(dI1H]Ru掁ɷC"CU?Bؠ~ϐ/Bp\+TD`=,GEJyl,n@e yes峂dQUHi IDAT#&qmo[ wjMqηsd7]l~6l70vWAѫ|Ejf!6.FJbdȦ]&Iuw,JG/9Vlr^ ILv6UHL"°B ڔ\3U/,ye .`$xI*9)+%Pc~Pi|qC[0ܙdyߩJ8&R#nHa^t;ZunܑSέ}Q}BAg c/W\Qy掇 QPNl!W=Ÿ9׹;*d ϰw3m7ӝmADP 7tΪy3V| mպ#>%cKg$kJU"5M>hʳ"$eb b$LجspT2R›Gx:d Ͱ 9QX:sE!{!9ŜC΄0x %U[uNp&y| E[@9UuƉ ~sGv(Hb3}0_SE BT` 1*σ/Yt+Ks)9RVa 劣U Jխ s.otg|"o[:CMT{QsH{la*Ir̾&J7 q%HM]8y7lx xesn6`>;\0*D`g~Jl3wBƄ:#\CEyi pMtƙAskM) 9&]m~kk]u]뺺ݹ:wϦǢDvOypR1R W(K;w.SmNc@ܹvbvuwX^X=w3ߐ SZsQ@Ǚ4h'891 D6H+`]2G9;3sT?OicNx_c\s;)I7Kj:uwV{d3YW`L$j{/H*AQ@`Rl]'"5m(( M Kpԋp*.zu~ T!v8;t@+yeȝ&!g- ks?=nX_щA~wK'I3'Aݡ&oCusS6; ,ܓү|iDj0&j$4!Ę`UB]!\30wg%Zíݜ ȳ#JS}0[^^L.i"58Cވj999/XO,&9I_҈8=J L ROict~w(dR4i״~wr_Ҙ D&ց~K"E Moft"QJǏwT^DiZD_si>xNjշn5zy/%A.K;.Zku'|r1΍pB7Or*,(KJ(T}נ "#3wŇ{|27-+2{sM+1_>W̊ԔA%ڇ5qa^䕵YJ[z&U)SHMjQ><ߍ[H>=m.a䓩K J 2je`otqa;h2"HUJ|ԧ>G|o6r9շAɕ`O3ʗ9wk,( |x`waeq{\z0L#!)2JJ |+ ^-lY;%t/or #&}'Xē[ouNDI>VI#6WNі9GEC9$M6UK.h(Ȓ9`b3*e>(馛f9 cM"9Q'|Ɜfe˟:\,J^")#|+_ .袌'K͡6:D#!>P$IYY|s-\LɊeSK.$ͭp >l="i^1o{&k*F,C戼%;cn [po]v%mOkrNh[ -גykJ@tyߋ|gMj2V'2@^sϜ8w)gwyg15Ȁ&rsڵnid( z"+9N&x9|QB [>1ɟK&@ ^p;z2|3a7VZ)3:#;Zcڮo{۲+u8ý֌WWCξdqsu[r`u/}L5"#9ߏlF[}Bn/#gg&Yd~˜H}&cAX ׋3KaBFaMr-QlʸH0+J˛(]6Ce\Q+2+#6Y!+Q\4}ꥨz&L×"`'3南rNfMBZ(S%54RX2(ȃĔ6|?+#8ϸs]wu.iqiOrKZc5j#pmsRL 6_|qNӼxAGl m=F2%vN6]JA!PP)cƩ]-'7Bjg**(KE@8 nf$JJGix#dCyq(gdh$W kF>t{_clRנKʸ9"\e8ͫ~(0/$bDuh=IL}Jp!5H̯^rErw%%cko2kkowJ0ywq#8>{>g]#WHO78++9DL[G"W:[5u:rhuK~X^:qhC.nU C$D?F{O?r3&kfP zƏSQ7"5ü[y)N(݄wg*ҳg ZP*r.~SB%*SmH,?6VdpjOV *Ptvީ}I ۰lrhH B*]+Alju|xRUw"'[X .H Kw|zEY }~ ' B:fRX-*V 'HNݻ5!56RiM]1G5Q', 'ޔN8mh;rlQeJPnl85)N4w6kJTXGUNE'ݥtw[%\1_)7>,ҟ{y]/)Wd9aU]{r7g B R{,d kN:eZFX=0UT'W(_WLu؀X8*rN{k~䴳,.,䠫Nv ﷦%NȺ{Tơ =B{m%nWR3F@ HM;aܭfRcc(;%R<:\TISrb卵Ņ"JaY9բ4h v+D@l6^nZ6~Dȩ's_LJa=QĕrS:. ;ɤ Dn  mUR^>(6ۥZ*  TFj# Rý\q dFRNj"ީMX,5[%5^ݻ!5,q@r/g82JRN8R RC)cM,ҙ/K@2S$6MYHMk p!,6냔5^;5LRc^ %DpYHMql"W'sܧ$q-XOw?rEL $J,{uHMs3\~XU'WX#X4h~tn8ך!{G:Nα;|nePIHf)@? k#kYh3r^HM?r,nN}_fhOI6[=F`\fщX W M R6H9餓ea5Pl673Jw-J/rҋ1-#BQ*QӺp'R.J;-,RYɆiceũXj0':E _ؕ{NƣJjȵwՑԘi#<Ťc1[o+Z)URo&܁|5',5fUH=/&Z o,}12rĨ\W;5hRP01MIMRh1 wXnXXXM+e|+ y῎ VȏCi R;-ޢw)r\biT!5Eu8!֌v=[!yNf[_';tlu[Vbl"oOCcdqufhE@ qA_#5+(6J3\ʯ#5I9cRß!% [ lSqn8t^J4F֩h Ʀ~ rUPۀ,5,2eA=WŒ{h>Xx)ϲBitQ̗Yf_"#siA<(.Std#U'-γj>*Mݻ|h7Nz;IoS T-H L9,Bd3&PTR$ Ć>Rʌ%ؗNqiDWk.pkRhlRנKklrt#5:pQ_寉\tFo|_ϝ7s2/+vpw|@>Cj( k!5 Ru &Wp%Ϭ}lDTp{0u#:}ge hmk7LnuoU-t^WGEBq#g^:~KXjvLHjcSP 3!tr;5vxBV $lMirOT!ݩ!s0błz껌6P@)wJLNN>ĨW tKЧ[ *~!/ƣ@%]/DwwĆ9;@&5w YE\AXX?er]h6> Rżƪ?gy੧SϚV{ P-u}ׁo[;&g,+_)}&Ϧt`vk\fщv;>G qje!Я1.dB V(~K;G"59Z\.fY h[oF'ƻ3K?(;\PEeRپF@%H0GaR3a6Qm 0=P@)|ѵG_fB o ::8ƨ\&{&U׸?n˨ *lKvH{yJDQzj^"{sȕҭ>Ix|+rJ##wv?'ԣ b 0P|:rrxėuFn:ĘA^,5H񂧓R AZUr’`QrNWR?I7[> JH|\* ɏn)vj+TqH"KJǸRwuS[zޖյ8㌼ &;-5usܼ[]֒4T\&JO%^>Xvkܮ[yDs2zg2" YW:]m%.䒴*y-9yH6*rPI)cP'sdx?uurYe[PN+ϫa뫲ĵr y`֭=Z3_76 qLOk%5mkENJ:G:bAr>fBB.}_җf"믟 HarJԧ>qB\ʍĐc߷iٰ)KJIQCjS)B6oB(ֶͯ}B6d| OFqJn%y gQo% a I8<']s5i-̉;Kr9}a;JF[7R-pEB(SDSuk%(x≙8 a&|˻EQC@J%z{)h "h vIoH9;q~m/u}JJ h-ݚ󝭷:} 9YD6TIMmΘ;5sB.k:&Z>^)'XȆ֭S"6[+ֳ֟r5/!DY&aCS! H4J9":pU?u[urY=yYh~#`XfUYly~ vEѝ{Jk~K6ޘfLOk'85%` PBodeQ,6,rHP%I$5NYߐuPIR*e O,#ikAAp Ez9RC7a;!N.$˦^9!7 SHz (d1#GHzYrTHSgߍ%ҩ&őˇbF.BA!֑]739ĸ*=uY'-B!my%Jj~ٚ"_ϰB(iJjok@"J% J5rNv«.(ۼ'#m! uT{rvjW%1prMkan"յ IDATvYH Cv9oHM?u!(r"XYU8bFjU)uik~KqI7Z8(SФ~IMQʽ̳>{$DP&l 6Rn!K.dVP1Tl6# *ḕrW2wQ乑P&N(6~BxQ/ q)GI׊v"sP]wlq@jϭ85tZH Ic'a U,5 9rFZtEϿv̕C=4)sWOsO>! LcuJ!aѣu|/e{VX(1JH!]*jcK]ߑikk$W#֌BjKy_NIF5^u g]ÕEXi^W/e%y[@YgM!W,rYBU-5މK n2Svp.2S.;.tg)3"K:I !VԵOkֽ_Mm ~I͸/Rd_||F:sWHj(͟*ix"a$5tD4_uLxXnuuY Pmpݍ% 1kc r a3ClNx(KjXf7uR-rpw놁M_Cu#5pvNn:0R\:F(oa,(Me\G(&7$("Ժ*$wKZo|iCGh\m|oϏ5j&Rc.Z5rgTIMM[Nvi3itMY>:l ouԄ8Bzˁ ˆps:IL2:%㫤fNEKnаDN.u%Krߡ ٪,Tqպ+_ԴiԢ-"0)m.\6[怸 ,,"zMdS4/2ٍߑRnƙTp@ + bRpUi,7 mBKl`%3W9uए)q=ZX.dԇw'FQ_~^M^?);H vy2O85+0p?_|]C: ;("XjHg䁒H0"Y;^Nw#S:ZRX*uRý^ڄX 1ep=w"VRC馔)-E;+<ܓ,X!I}m2\ōּkdW53$8Ю%R% d- y:%CN;VR:3<"zŪL8\AԐ# +QGj;d|'Vo\OZBƐ'\!#Q,qXPJ;"Q}(@nʗ 5m1)_RӖD6G6gE[qZLaE_䍩УMne]._1buꖘab` \3dJ ՍTխ: C$zmqlXgC*Wd!1W:jO|;3R(*(QnCWW["_t#.ļVA!Lq瀞kޯg^:|ߪ>:w2#[(C~! &L~ɜ~e|g[&ՇGJl{d:SiҞ~q/Aj u.rR|KUr~@i@y7^nr92h\|y/^!W+/n,Q~Ir#|?ٞSe?EXUtJh=>o?utSp(ɌUs#hZuvrQJ6!Hi9ַAsVe"PR3.G^>+ʭzt-7 ~H g\z|_T~!gUG]huaT_-6p?k(L_%5\@%J\[򥍣m C_24R2K.mKjX-A$$?Y7p4<Ը<题",-i2;VJ\uZX*d DN鹘9BӳR!e.QD94m3X*3tᢗywg4J 0 Kj?$5GۛYm-!_:2Ѯ@`fȗD&~I a.ERƈ2#L$2h.̉b/R|*Eq) (y#R ~#S'37/Z";*|5~L`JS۞tI~㇏y.ϻ\]"[$#m­ v0fE~IܖKm2;k!_4і@ʗ֐V :""'‡hHpug\}nPR'dI=r(:C:ܥy FHv4!,?rE^{\!5,P¨",D y 5.ۺ3TF^p?k!/XPBG At|iD4/!5%g$&hir!Cɡ0ӝ׹~4ŤD FNa]j\Dj% ʽͽ #npː3!!Y.y^<[#zĒ4 RB ~I;kB*G A@b?{x["y!erS4"DpºN8ᄅFe%_cJdT"i,)9WI 2ⷿH@!5,TKX%C>B^sKa M߽"1rR.bM8 Af R |#;lc@ h?ܺﵽ|iEh*_FNj$RdH-첔{nY%/e|wgXi}IsL\f$sH 5|W8eŅ֍ >y+:!5%m+x'> m*i>VBs9'W +UV#f~֚; 2H4%5,p~0'e}9mg<0Z[oV=W[m뮻M74UI 돜5}kӖ[n/'@SΘm6׿{u]7} _w亾z%|^N>\X(ы#fM,֬/xŗ^zi{r|?Q@ -!_f>7摏|dZeU҅^jQIOzR)$D2N֗n!ٺ#1_~_n-GSqDN3<3`zAC9$! }wĞr.pZfeÜ@]W\qE&7Fjlz8{f_|q> :c|L!ok|돯@ XBL Kj~_%fM6Ir뮻.]tE9&H)>G?t7ﭱ[=ߵᲾȇqw߷"@Aj>m&"'Ow]9`;sB[n%ZHP=Fjl2q[# @ &#5viiUWMW_}ukߐƲR "ÚSO:ߩ(@?ȏ[g4Aj$f=檫J/yK[owϒA 0AD+C~,d$ľ_<YdmU664t@ tC>)GG/Q^UBX`u*&qE[ve~9"xG?U(@ @ ,1@`X|WĒɃ]-fZi"8le:묳ҋ^v5:Z@ٛaF@ȗ42HH`nKazCV/{y$X@ 0BQG !%EA H< GuT:sR]tќG(@  pQG !%EV?O Xl@ @ Դ}ܾ&4C;@ @  AjdUZ5aq@LHE;C ٰZfXNP5tPwi&hࢩ y'd"emHMR3$`'Zvit)$.hQ@ $t ͨ+|PR3W|%:$?k^{>!eU|,Q[ 0,f+_ kDFPIL.P|.!=&w@`LpAmK^1׆|f f#_ԴaløILfP|>|ӽ.g1M AOSZjm{  #0ų$tArz!\>ӱ!79 o|ck[C fD| RӢ 5$Arz!4?^my 0p+ҩև|iDh*_ԌqBM;m^͟8#l ;I|iDh*_ԌpFw 9]wt%Log#MԈsF R3gW$fnə~}m @o)#dL 3@0k 3\|Y{5vƻ@}}1@ H@]?\uU~x`oc`FUhm{F<\@`h~R`h4k _6s}{'#J+C=4/_b%",2&Bk8-Z@`}}= MofD^.Br~ߤI7tSzғ~zы^>񏧽;HtPl뭷c9ӻt-W}o+[6Z=+!MߌC RS=H&akTsE78!B.lkӟtꩧs=7]ph`u5Z{iTA D%"dN 33%)g^:M|~LZ~_e73AV^yLb/<9ْS,5+bG>_%h.܌&BkT_żhQ2wQ>} w@ڸ~}fp+!XNj&wIM 9 9u "t+QJfJ{# |yC2v{7_bB/A%~_hzDz㠊9v-]|-oyKUqWM?q]aEI^W%9~_9YڤDW\qEfm[j{惼!v}~7=)O y3U뮻nox{t>-ymK;Q&HL[ 3#Dh7^9Uc;W\z?9}NO|#9zj AI"2!>V;-"5i\> n^4)y5ӟ4ߋ+e\geԤ{lvErҶ;if>ț;#y鑏|dț&Bc-rW,Lmmᶱ4/&5Ab8M"$_Rv{20'tR:r@:묓+ox;r9X!X#/;M_y}:qNa֦Mo~[ִb-}u|8$!`-f׭ nm +|3i=wn֬,#l[/[Tϩ\"{]rJd5u#}v!!\ӟņOO/| (gL:.c'?9}{KO7-L0 fܺ.??Y]5$c喫cjek~-kFhr(om+ַr( ߛO|"ˁ@gXn()׿>Qظ/zGp뷗h[棼-@PC_w7W}[:{uuG^kl7Y"5Ab9-q#$4ZGJ!"=yYaf?p1$'\wn??yS@^{eRq 7OM'BKyDsKw2P|2Oɭk͍~UBҬo\_ST&tYW 2=-N;]l%{\Gs$!6,^ȏuhP-yZkxm~_`1 IDAT:*aw!HG}:O~oo Sy'B>=F QW /ơ|2.,ko"636ȡ77 Qq^}m[棼r _B2!#VZ6yH.oO}]&7xcYHV#k &w&HPATrcAg<Y9H:Eu3N(ZJ/'k.Pm65Şbʪ@/)6zfG7q),%@VӦ%ɿY Jr>66zrh &^'{vQ)ޥD_E / ^E+9$Aae/!x≹MRt˷dLXp8KG(6 V ^7XNXa}ӟΊ: eG,S`݆ bsa$#3t'[rS 6)d|fvqcaA(´{ \;2. n$*ooOm8\&(,6S/dl
[:]Bw_i&dop`b^6yҹҚ%5Bb  rֹلEl~U}lږjJX c'/n# l)Z&{LrQ\rwJ>5 )Ɉ5m~PPY5(."=(ܥ총[McRtw<(N(6w5zLQ,A1O)ڔvaBqK]^S~ 瑠rY5CtTa 'X_ȧA<(XW_}u&Xk^Wj $!|C ?!Y7Hu|P8}Tԃ챘fRܨ֭Ts=,0d1Dq3ssR" O*}V yKvvOݙޡpAa(7Zq1.MQ־q!Ø&;Ly5=#ex7yC#>#c|&cwn8lMHʹ*xe9 QFdMXq6A)&/?pJBTд4mK5lewټRCBnԵ\9g'uJWeC4Z; e;g))D7YэJ>bLPOfի[1)bݲpc*$%Wa}_)&چ,L |M0-ZY ,I.̚:LM#on喞wUD8B8&s\WQ75u`q^l[BބnAJ}ĜL9@߈hVW(8>o~i$1Di lX1‡[Q&D%OXSOlj\3L"iN(uN4Op͠9e`BRLvpr~ÜH: +JFYArկ~u&i8X6_7'–xG]na+*łw gmw̦\]u:m~N}Aqs2o6RNJ@=E;wKB9#qi8C:wO։0i_bX57m07Ǥ~^Y̻~f+\ʸxh@&caaI,,ri-Mq`Ǭ;%o8?߼%5̇^{m6w"8iю( H$q믿>+#wB]QK;cv@,ň%e#B",Vur ֗^zi&ܯ+e4.,?.!AƸPmvJ뛺--mA"-uarKX\E6yB.$}"ngc'&ƅ+B"R*uI)A|9[)ڠ=ſp0M\b]`h[aNMMqPM5ōu ?nǃyA&kR /ͨfj;M>d6Դ$xUdu tSnaDj!߈u{%*Wu: ś+Ke_"9',<%,"IVr*B rS8q 7ʽF_RK8Ċ9rayB]FY+C.\.g6;(1Bؐĺ}OԪZjԌr]#DG_޾q` &&`I]Dd; KF]QJ[˶BI9˸N\uǝ7Xc>p, - 'uaa)Ȕ$v #(H%JS!L\7.]uaKE[D 7X]\/~5>dJjme}pi)rƥn!y~hCRd|}T+wjBfD*jDR^Ji& 3X(NRǁWȗq>w|m6,7q;/u+RSF3%W߬$/,(q*aAYuD{Oiqab,rĢ#r]XXr[>,ċr)R w{9Plԅ-e ! i+2T]2oQ%5~<*ʆq'Cʍ] 9!"ݸh BjXMk$No׼JfBj%ؕ(_E-]7=ra@~J4H.8qpa|DB\8q` ]s1q/lG/s@Iͤf[պ0G @`l(.L"wJ6, a (EA(ur(uaaW}7K26an] `,J)u}u [ZB4r 7ŭ.leb0 S;`Y^n~%.9a-]ʿ5 D螎(F0ºD9)Z#&W$Nv[f5\Fj!Šhkg]GR+*" oeT)vUB/oWݾ/F 5!_0dL:ɩˆvPݕA>ݛqy]~6i\Vn.lḛ sB8t馛jhS#ÂR)PBvK׫M|HLS̚uWon!N^ĞEU=kT=]@!9ccmr AR'TNH M`A0LJ܉c%E.9AF@“U;XlYNKH%dgYD{c| ~SO&3jGDC$tF~(,ucnQjJ~h~oX8SAdAR׍9U, LF}}D3k~w?v:c7Y[ݝr.QٶyT  fC 2sMHI4 Y2$f\JAcHٷe]wߥrd!\!?v4B]NKMq?[%k(%{$½! 4+$B"[,+'KC-r咅٨g ]pDָj 3oS)ڬp3rHG VvF?ԯ(mLEEtwP$T=3[j][ح 37ݮJj:Es <Wdt"7AP3IB=@dS7„#GH04Q:!Gվ/!_r3`V{;VR$g0LAbS5ѦR#u2B woɹS~RCY/O~< x /̟Cj%"7VʮS_I<댌NmnA]Rt$YЩMVYX< {7ׂ)[[J2^ cGu +q$ƒG*Xʻ:vbt"VϺݿjnm@ ;,ڢՑRLc 4%?~#!pF$,,y3B$ 猕$weTwUB|1G 2sMoHMLk; N)"XQDi4; qwXj(S9w/e/)as1&vBs-rjT2M԰n(`"㔿X2.0]w un.ύqm!ˊVH lYX"DXu>maY rew6}eB|1G͐/Ym5 3  1ǹК[/7*J,P',5(DIRSGjX&qO*!ԛXjHiPjY`$S|[.F,/#gTpsCRqBsCX 1Q4`|] $: YoX[(V5.6&HM~{[DnmpJq_:Gr,5պi/2]&WGAj?!rcCJόCX=^UH/84 pԐ/8 eHMs38DGtp墐RY4( RCN$E["YKw(ڔ{d©; H繟Յt}ZH RJG"rnX\ BՐԘڂpg?')4eEDDfV 0qBY))W\׸9l %ν"7 2VJgC.w"RX)Bwrtk2:``.dn D/4$BV *1T UsruqeTwUB|͐/] ?dN4 3 @pͷ_}2OQwFrўU JJfI[n(Ch]`Ѧ[Lq)TԅpwZDjDʐ&kh t\Kg~Ǥ.*"<۝ҭ_m.wU}pK޿3wcs;f\ǪbLƇ}on޶/r3jtߋ1ֳ}S;դ&Hl|.Hƽv+VMb(E dz{o][Ծ˽f?1=Md+R$4i7Ziu$ , 4Yə_(HL{ǻjo/eD훟&z@fLE0*?bkhķƆ@AMV@ *m_moP'*&&7HM1|5H@+2 ! h@`*h~a əxx4H@lIMVK@o)#dL 3@0k 3\|Y{5vƻ@}}1@ HM@ Q7IO1Im FAj&nəyȂLܔX]wt% (F@o77@gcNrČqիJ:+ܲEs@`&$qW_ZBvha4/AjZ4idkYSvaZk׽u-kY4'fB??W\N=C fD| R 5$'HL'W˚| |=yOZ xߜVXa7@|iDh*_LЄj; 3AM]lwߝ]t.L,L{ZNȗQ40Xȗ 5Kr<{@_^צ38#=hQY oB}}v1>GuT-]vYo @`"pGw頃J=дZk׿Yys<%*kwO.@ X@9E-kML@ !_fzrN=԰l6ferWp?я1.}lӟ?Mox2Y~3yqyx=}Yd#P=E-= kdi>h !_2jGvZӋvmieM/vGN;-rv*'gCx㎜Ls7Mʬz"۳/ ?sZ|ӯG>2Hw}yI/r{v:HMOz??'`m9L;1#nV~g4yD?Bf[nlyы^4[+sWx=zL)m@vm>v}쪺Li[@ 0LB ɮ;HdZߋ (};7zo IDATh$#K.w|9Y 4+b~]Vwm(8 _{Xnt!KLWo7@ 0JBv+HMgdP[&wuW",hwޙ]VXa '?IzӇ\!= A!ePHN~=Aj& ҃ 1* BĴa!eXN^Aj&o/}K.bE(hdɸ%"Џqw?{վþvat7ε| L(ȗ Z4!Aj6M_׃l饗N{oNi/xGKo~){CwqI{D2?R3|A~| WuaCI~uMni: kN8'?9}{KOθg#V@ /LFrR~ ЏW"]{/|79$ӞzV{wivۥ?9⡇#,Ox`N,ʢDoV]uմ;XF A2=9&41Q@`tl6fe"V_s1 /I{E\t[ZB#=/|&/厠$e9Ũ3Ƅ@?eLM׎Ԍ6XkJщ6@/n~_=裏NvZ&,Lx*vaxgs=7I~YeB~JyֳgO>у@ X@/P̟ZsA% 6.ML@ 4??R-_|'fUVI[mUņ~%2 YnUG=#n+QG<^cD|c#F H͈oD;-(@  ^J"?v6J\sMZr%:묓N>tW?iV\q4:or% ^ez 9Qfl0.xt@ 0B(zoIȍ @~|_sk[o5ߟJK,DZdEqwfVXaw}'?IzT%C 0^̚Ԝwyr&>W^9D #Piws9?BJbĥ w5%9Sg:e:5z5 R{M7ݔ/fdNh"tz7χ Gyv4z6Vz^ |2 T@`0UiLjf?Za!^{CѣsE`$B(>+x> _&[͌G OrwD  0lӫ-Arz!7A &hw#0Z{ u   Zjn3!0nktB(> _b~lI7|s:c' hm 0xߜBoHD 9 \&Kߤu{]fO&2@`"eTSO=u"݆^$B_G'KߤFO?=6OCzqs>(i'1F2HN/硿̟NMIͺ뮛.B+zbN@wkBhz?7c=~߾I}ыS@ 1s 9so7Im F R'4 b[;Cxw 07 5s:&&Ba":4$f<$|UW]uUz=C ̨*1M!5~/R;$ߒY]$qE"L Mtz:$6m$KsO}VJzhzK_KktLwuWgUGMO׺&lX[/=D$Ạ&˼ 5[=,}9gǧ>g>3M_vyt9䰷\pXu/{˒G?z w/lZ{gzc7wݜHv‡?vFm~?yzS~ϺַKozӛZ8+k" 1=Br~䃾n)=IOʃ??E/J{~_:([翱]zW[+^3k??77T}-m&w'|rsQ8ٗӟlpG>AӞ>%J6(E{G<YG6d.@կ~5]zRO~2 oHgz뭗d(};ޑ?bꪫf5nRG=*}_N,@&(kL4c~|#I\1 R,\3HM_ 1}5_TsE7 ã! $5M\?sLF:HM?5S%P'x'`җ 7lvn;,/F% C򐜣稣 }vH_^M74 N0;P^W溝|s˧?E` B~69`]rRƚapY9[r%g<#!qx& ?8sue,^5)Fr|;يkfn?yK{o.^ҥ67:묓N;vS򔅰!blSW_}lIc==-oaj?'O&f;ӝwޙ.ҌgL=wg:m{_Fv=Yj8nhd"Hd۠Z=)$#L]vYg2_}>vfOb+6l~AzPp s4䩽me>?*Eteݧ3w??|hc-`‚Fߠ7\x ׿uɏ uv?{˟zJ~:Hk/xAB@UKyՏM)ibQ_<,uu|DNO4MeBq+w=l} #òf̤OuY k/|lls~SN9%cЧ~69W?ҙJAN_z&s _ɸC8CZ0'tRuldN&IU-L{㲀&eG;.!M!5rH>bD#+[̈mqp 7mY&SbMDhx "@8Sϸϙq5yMތfLHrw_?/WUȲ-4E[T"E3A MbDAD=boXaX(w&JKvj7nϴ&op)W.$Euwy[ԓXI_$ 6P^(<[(-<=B}\*﷭V"5;S>:#8vU7}d_HoJ>;R1)@J;w5m ˳j?LᠷSGV~0@蛹% t/M)pݢ t(nuuB~WdƁ:á*7:!lv gCDҚܗncsEdk6ګo,k-cD*9wXk3t_$7 eȈ&wIMu\r/~&-'X {مcɓC5mbLF8t?@|ŤASk9eb}O~@4gjŃ9Q⒆xނǗ~V&mN5˳e 0&YX;ƈXiPDȄ:p(تR8Tgl4REts|4PAbƁ $!("2N:0sgbqIb'?X ӟ|f_}^b۝ʰ4 %UO{7}`Xi(N;]|f4vHR+g"5sHou{q@/[yh-HMYsSnNdnStXQG I,'ms"ύQuG/ $;c:CsfN̻ Vbj.UR( 6mt'x/Ij3BEMui#u{C` z}~t0I7ݧs𮱷#|},ꀟ6w-:%@'1.Eqqg3y ``˵w^&N}iqc~cUNKyUJuu;uX'҇y%B\tjzvAK_2cv&d%(3BRB?',0k' $^ ~ "W],j,Nx(Nf|F@Ojyx6*[)}ߐ~cޫ kLތ1t2eSW}m|.z2Q8?se;pJ,w=t=>]Eٷ*λ< &fu^ J_+`soutEqG` oYHM?D7}&M94~dN~Aӌ@08֑~A HNy/Ȩp{QgEG]4YAj=Z@`4 #hH^$f$0KZ@ |-Y`.bcԨm&7HM[F- &BajAb oT>g3߄Oh~ &7HMK'#r6TzuIJ^s>|Juj1Lq?D( % 3H4Svso6- Oȱ1l9-L%_-4g)/S> zȚbuG}@ RqFdDM}"%Dr).P"\Jg%BJ}UL}O EFìM(>=ҟnEQC_D}`RJ0>D¨;%?Ri_,zR6e_ETqRFyDF^Q6Vr'"R m&/p)CE_6"Mbq_E0!QE6QDN%wȇjκE~u]}֭pI[4)Bl{}c>&7H)MAo:DC)9T>u z5Il, ψՕ*)_|2%"C(Q"/D˦ΨvM7YOjX$N2H>Dq\~׾tVN%]u-U?>m98ܾPӦiaEX-OXRXQ0w AFČ`BFοYX|N8MH_?c~x8bˋr72f|URSI~&Ǐd2)WI LN>w%jci"~m8ڄܘ3! ۋ]N> Y&W<[9 +3$#+7k$h,VSF ]/5;˔:LN:$)ʍe: K"oz \!9WJkN:) l<&:@|MNϻ#f|Ȑ2,cdP~2RBhd眘km)C$b0gK}DZLdفk:d>cd39]׻cyYW^JaڈQK`d ذٲ5lM]* ΰat^Ito#=eCΝ;EH9!P{ ~o3sxO4@X[Ska_+s$5v=bcG"褨&$L簉 7\SA>6^^.exy*}pgde!BZ`,gt9:8C9a*NGc)_4{7IiCj'r"6L+ϔ.} T0{}9±]59l[ũlH C< 3P hȁ)Fѱo%Z؀);eD D,E\"İu~iq…˗͜׃hBDK(xONNJX2 )Cf"KJxFN}#õ-ƣ9I'N~(CY~Rr\y`B8Br 7d_Um/k73F*k1:1bHaGTCQVKO*b[Fj{me O͚kM ϩG0:ɁA#+{zgsDL"i5`%bRGRϲ/H  OZDs8EYNa>駟.Ο?0@~g EUEL` 6= w:k>@2>xL^,}W8F&?sLA`7؃|4c-EEz@s3ҧzvP6c݌mtlrw7Zm.a.tf)(BXGzRë@9x* ",s"ói2X4q(?/*`܈ψ ^G}S&l x!i0 1BW\4Ҁy B(<%}en.RKC9Uf [m"KlnHw2eY/KIj[$&`˩FjGλ ,Ȍ $g[V&7fD*# &lm62u+Ǟ 5Nخ>qAHMhc+\C+%8'NĔ3̾caz k bcpD0~K:-]Q}8YQ(pyb[0 {:Q39WAjV ϑlqڈg9'ˢ%C88B &ÕA#LIBDfIl\ 7x,4dqr2cd[x6Kk߃&5&UӢ!~dcc Pla !#[hcHk)+W:0[R`O^ bojcĀG7r4nYR9DI )덝abXK)<Ea\|H kxy42><t(RC'y&!]xgjM>oq~/I P. gyf, 㩵3ҔedfIr633 PRČ %ax\3C4 @dȐݴ"[Fn5أ:[l) #~,FV䰙2AoSxX,5y,,`)-[FK^nYFX`(=E 3"/ gы40& ؠ0AmKeL|'!zЁ ` ,>l<KMNtJ`Ut6.q)SQlBƍ,!51"C-25#51-W ,uDDbbegKpshM,%#"2~I0 :lC"-E2v< qbGNUgX !9k]-{Ф& L[EkS;2a (3-V-Si }\%p.#_ƍ$qɉ$6 yVD{lY|13cHS-B\'B)MĮ$Mٺ28k*BdeZېd7ϤlmQc"1r^=)"D=]W,g#*,~A`D*+qy,0wﯵqtVPFCǾ޷1prx9q'GBe<EWRZ?[(H1Nb)%I MwJO-9hjϚP0v"1HlY1"'V,Q'Q!"MZϦuW&eoR[@QJ&$9$$fXi/U(D,bw8]-fn㝥Z\$g.3}L3~^U^Z7I͆g!*J>.%p]-FabK3י?q'Y}.F HŌneoyQB kM5\r_3$C@z)3{,(˘Gu%#$1H{mNEr-k%a,%cfhJԫXgyΜ!dȳ4; "J=g˨XL ց=(w\ հskK9e*4 [{kّ8^9B0ot휋M{jx<;;_~euo=~ 8j5 w};QBŁX"%=N\E!D%&H!:5o_4P(ɼ=,"k 1w}G(&RB CD|J}-s1y0}]'?aW~iACC̄BDR~ü A!RS;LZkZB%-hG]k@{mrHqމ8ikxGwϝ;xW;\XqF?ӵޱg`@h' >( 1ޅ!rp'5ҲH4|衇:\U6$(2P!R*&'tDIԧe<;YAXRX >leAM-;KRFԁ(4@6ؑ $5W\Y|g4^ 3BGFxO( 5R0F E:hϟ!5ERx(&20}R#bt4%Ƌ5?ߤ yoDNOO;RZ9CASީ1i{2 ˆ0D6W䕤f"숺$&2$1ӝP4Q, 5)*H4.{q`!yn ؋k{}_~p0Ajdk !H!B/#5Om؀,IM @pR Rdp(װ <'5"DcdNIjnԋ$5RcI p,;P&J"/#5 t)yR2k( ab=0Ԥ]DDE}k"Z"̻!z3 n#Rؐ"wPb4 5Wr]?]E+O+gаoy\ {df._19r|V6ϑ3#`2Rè=B˷^1M}3ߞ%9o_q*Jɱ CH މ:P[E&8"ᕸM}~m_HᨌtveR22n oiuC&X^ !.&IM̒GjDlgH4&iU9C|d/}Mh"&ɛAYNyH+ӹ~@)}27,,Rx#8€DW|P'Q`Q  'x^_ʏ!KCY( "ouDGy HY9 ci"ϛn):Is3'l4k2oBښ 55Y*.껝chsyL{L{~ݜ޷Ho[dZM&أ}3@eK?GoEp83k5RI$ "5QyhpO DojJ\ qq|:!o0##'5S8TXo5$- CX ef摚 a߅VF"Hq~˳Z\읷B_7(Ae5 Pddi<: !^ uaF~<K%:P{K gC 'yl15s)M;I&$12sj{}cpL5wVG9ɮMC];$x:qr7zT amfT''xQ.R3.. V@6$1Eup;)hKGDODBdd; oØeJ`- ^7LIruwHw;|_Ew 2i\G-;vwR)Ó@Yk-$1+xOw%0- _H^xq.{i>{H d[o];)6IH K.aH`ULj7\-r^f"!g}v&ɜc뒜$1|g\)J`]!o;_^{k￿;([J %{ ({dk}⫯ʔOì߸$Lj:33&ʤ&?vƖ-%؝x9x+R(INmƟ\J`H]/^vzIENDB`django-axes-5.0.7/docs/4_configuration.rst0000644000175000017500000002314413500726664017474 0ustar jamesjames.. _configuration: Configuration ============= Minimal Axes configuration is done with just ``settings.py`` updates. More advanced configuration and integrations might require updates on source code level depending on your project implementation. Configuring project settings ---------------------------- The following ``settings.py`` options are available for customizing Axes behaviour. * ``AXES_ENABLED``: Enable or disable Axes plugin functionality, for example in test runner setup. Default: ``True`` * ``AXES_FAILURE_LIMIT``: The integer number of login attempts allowed before a record is created for the failed logins. This can also be a callable or a dotted path to callable that returns an integer and all of the following are valid: ``AXES_FAILURE_LIMIT = 42``, ``AXES_FAILURE_LIMIT = lambda *args: 42``, and ``AXES_FAILURE_LIMIT = 'project.app.get_login_failure_limit'``. Default: ``3`` * ``AXES_LOCK_OUT_AT_FAILURE``: After the number of allowed login attempts are exceeded, should we lock out this IP (and optional user agent)? Default: ``True`` * ``AXES_COOLOFF_TIME``: If set, defines a period of inactivity after which old failed login attempts will be cleared. Can be set to a Python timedelta object or an integer. If an integer, will be interpreted as a number of hours. Default: ``None`` * ``AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP``: If ``True``, prevent login from IP under a particular username if the attempt limit has been exceeded, otherwise lock out based on IP. Default: ``False`` * ``AXES_ONLY_USER_FAILURES`` : If ``True``, only lock based on username, and never lock based on IP if attempts exceed the limit. Otherwise utilize the existing IP and user locking logic. Default: ``False`` * ``AXES_USE_USER_AGENT``: If ``True``, lock out and log based on the IP address and the user agent. This means requests from different user agents but from the same IP are treated differently. This settings has no effect if the ``AXES_ONLY_USER_FAILURES`` setting is active. Default: ``False`` * ``AXES_LOGGER``: If set, specifies a logging mechanism for Axes to use. Default: ``'axes.watch_login'`` * ``AXES_HANDLER``: The path to to handler class to use. If set, overrides the default signal handler backend. Default: ``'axes.handlers.database.DatabaseHandler'`` * ``AXES_CACHE``: The name of the cache for Axes to use. Default: ``'default'`` * ``AXES_LOCKOUT_TEMPLATE``: If set, specifies a template to render when a user is locked out. Template receives ``cooloff_time`` and ``failure_limit`` as context variables. Default: ``None`` * ``AXES_LOCKOUT_URL``: If set, specifies a URL to redirect to on lockout. If both ``AXES_LOCKOUT_TEMPLATE`` and ``AXES_LOCKOUT_URL`` are set, the template will be used. Default: ``None`` * ``AXES_VERBOSE``: If ``True``, you'll see slightly more logging for Axes. Default: ``True`` * ``AXES_USERNAME_FORM_FIELD``: the name of the form field that contains your users usernames. Default: ``username`` * ``AXES_USERNAME_CALLABLE``: A callable or a string path to function that takes two arguments for user lookups: ``def get_username(request: HttpRequest, credentials: dict) -> str: ...``. This can be any callable such as ``AXES_USERNAME_CALLABLE = lambda request, credentials: 'username'`` or a full Python module path to callable such as ``AXES_USERNAME_CALLABLE = 'example.get_username``. The ``request`` is a HttpRequest like object and the ``credentials`` is a dictionary like object. ``credentials`` are the ones that were passed to Django ``authenticate()`` in the login flow. If no function is supplied, Axes fetches the username from the ``credentials`` or ``request.POST`` dictionaries based on ``AXES_USERNAME_FORM_FIELD``. Default: ``None`` * ``AXES_PASSWORD_FORM_FIELD``: the name of the form or credentials field that contains your users password. Default: ``password`` * ``AXES_NEVER_LOCKOUT_GET``: If ``True``, Axes will never lock out HTTP GET requests. Default: ``False`` * ``AXES_NEVER_LOCKOUT_WHITELIST``: If ``True``, users can always login from whitelisted IP addresses. Default: ``False`` * ``AXES_IP_BLACKLIST``: An iterable of IPs to be blacklisted. Takes precedence over whitelists. For example: ``AXES_IP_BLACKLIST = ['0.0.0.0']``. Default: ``None`` * ``AXES_IP_WHITELIST``: An iterable of IPs to be whitelisted. For example: ``AXES_IP_WHITELIST = ['0.0.0.0']``. Default: ``None`` * ``AXES_DISABLE_ACCESS_LOG``: If ``True``, disable writing login and logout access logs to database, so the admin interface will not have user login trail for successful user authentication. Default: ``False`` * ``AXES_RESET_ON_SUCCESS``: If ``True``, a successful login will reset the number of failed logins. Default: ``False`` The configuration option precedences for the access attempt monitoring are: 1. Default: only use IP address. 2. ``AXES_ONLY_USER_FAILURES``: only user username (``AXES_USE_USER_AGENT`` has no effect). 3. ``AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP``: use username and IP address. The ``AXES_USE_USER_AGENT`` setting can be used with username and IP address or just IP address monitoring, but does nothing when the ``AXES_ONLY_USER_FAILURES`` setting is set. Configuring reverse proxies --------------------------- Axes makes use of ``django-ipware`` package to detect the IP address of the client and uses some conservative configuration parameters by default for security. If you are using reverse proxies, you will need to configure one or more of the following settings to suit your set up to correctly resolve client IP addresses: * ``AXES_PROXY_COUNT``: The number of reverse proxies in front of Django as an integer. Default: ``None`` * ``AXES_META_PRECEDENCE_ORDER``: The names of ``request.META`` attributes as a tuple of strings to check to get the client IP address. Check the Django documentation for header naming conventions. Default: ``IPWARE_META_PRECEDENCE_ORDER`` setting if set, else ``('REMOTE_ADDR', )`` Configuring handlers -------------------- Axes uses handlers for processing signals and events from Django authentication and login attempts. The following handlers are implemented by Axes and can be configured with the ``AXES_HANDLER`` setting in project configuration: - ``axes.handlers.database.AxesDatabaseHandler`` logs attempts to database and creates AccessAttempt and AccessLog records that persist until removed from the database manually or automatically after their cool offs expire (checked on each login event). - ``axes.handlers.cache.AxesCacheHandler`` only uses the cache for monitoring attempts and does not persist data other than in the cache backend; this data can be purged automatically depending on your cache configuration, so the cache handler is by design less secure than the database backend but offers higher throughput and can perform better with less bottlenecks. The cache backend should ideally be used with a central cache system such as a Memcached cache and should not rely on individual server state such as the local memory or file based cache does. - ``axes.handlers.dummy.AxesDummyHandler`` does nothing with attempts and can be used to disable Axes handlers if the user does not wish Axes to execute any logic on login signals. Please note that this effectively disables any Axes security features, and is meant to be used on e.g. local development setups and testing deployments where login monitoring is not wanted. To switch to cache based attempt tracking you can do the following:: AXES_HANDLER = 'axes.handlers.cache.AxesCacheHandler' See the cache configuration section for suitable cache backends. Configuring caches ------------------ If you are running Axes with the cache based handler on a deployment with a local Django cache, the Axes lockout and reset functionality might not work predictably if the cache in use is not the same for all the Django processes. Axes needs to cache access attempts application-wide, and e.g. the in-memory cache only caches access attempts per Django process, so for example resets made in the command line might not remove lock-outs that are in a sepate processes in-memory cache such as the web server serving your login or admin page. To circumvent this problem, please use somethings else than ``django.core.cache.backends.dummy.DummyCache``, ``django.core.cache.backends.locmem.LocMemCache``, or ``django.core.cache.backends.filebased.FileBasedCache`` as your cache backend in Django cache ``BACKEND`` setting. If changing the ``'default'`` cache is not an option, you can add a cache specifically for use with Axes. This is a two step process. First you need to add an extra cache to ``CACHES`` with a name of your choice:: CACHES = { 'axes': { 'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache', 'LOCATION': '127.0.0.1:11211', } } The next step is to tell Axes to use this cache through adding ``AXES_CACHE`` to your ``settings.py`` file:: AXES_CACHE = 'axes' There are no known problems in e.g. ``MemcachedCache`` or Redis based caches. Configuring authentication backends ----------------------------------- Axes requires authentication backends to pass request objects with the authentication requests for performing monitoring. If you get ``AxesBackendRequestParameterRequired`` exceptions, make sure any libraries and middleware you use pass the request object. Please check the integration documentation for further information. Configuring 3rd party apps -------------------------- Refer to the integration documentation for Axes configuration with third party applications and plugins such as - Django REST Framework - Django Allauth - Django Simple Captcha django-axes-5.0.7/docs/8_reference.rst0000644000175000017500000000062613500726664016567 0ustar jamesjames.. _reference: API reference ============= Axes offers extensible APIs that you can customize to your liking. You can specialize the following base classes or alternatively use third party modules as long as they implement the following APIs. .. automodule:: axes.handlers.base :members: .. automodule:: axes.backends :members: :show-inheritance: .. automodule:: axes.middleware :members: django-axes-5.0.7/docs/1_requirements.rst0000644000175000017500000000121013500726664017333 0ustar jamesjames.. _requirements: Requirements ============ Axes requires a supported Django version and runs on Python versions 3.6 and above. Refer to the project source code repository in `GitHub `_ and see the `Travis CI configuration `_ and `Python package definition `_ to check if your Django and Python version are supported. The `Travis CI builds `_ test Axes compatibility with the Django master branch for future compatibility as well. django-axes-5.0.7/docs/7_architecture.rst0000644000175000017500000000701413500726664017310 0ustar jamesjames.. _architecture: Architecture ============ Axes is based on the existing Django authentication backend architecture and framework for recognizing users and aims to be compatible with the stock design and implementation of Django while offering extensibility and configurability for using the Axes authentication monitoring and logging for users of the package as well as 3rd party package vendors such as Django REST Framework, Django Allauth, Python Social Auth and so forth. The development of custom 3rd party package support are active goals, but you should check the up-to-date documentation and implementation of Axes for current compatibility before using Axes with custom solutions and make sure that authentication monitoring is working correctly. This document describes the Django authentication flow and how Axes augments it to achieve authentication and login monitoring and lock users out on too many access attempts. Django Axes authentication flow ------------------------------- Axes offers a few additions to the Django authentication flow that implement the login monitoring and lockouts through a swappable **handler** API and configuration flags that users and package vendors can use to customize Axes or their own projects as they best see fit. The following diagram visualizes the Django login flow and highlights the following extra steps that Axes adds to it with the **1. Authentication backend**, **2. Signal receivers**, and **3. Middleware**. .. image:: images/flow.png :alt: Django Axes augmented authentication flow with custom authentication backend, signal receivers, and middleware When a user tries to log in in Django, the login is usually performed by running a number of authentication backends that check user login information by calling the ``authenticate`` function, which either returns a Django compatible ``User`` object or a ``None``. If an authentication backend does not approve a user login, it can raise a ``PermissionDenied`` exception, which immediately skips the rest of the authentication backends, triggers the ``user_login_failed`` signal, and then returns a ``None`` to the calling function, indicating that the login failed. Axes implements authentication blocking with the custom ``AxesBackend`` authentication backend which checks every request coming through the Django authentication flow and verifies they are not blocked, and allows the requests to go through if the check passes. If the authentication attempt matches a lockout rule, e.g. it is from a blacklisted IP or exceeds the maximum configured authentication attempts, it is blocked by raising the ``PermissionDenied`` excepton in the backend. Axes monitors logins with the ``user_login_failed`` signal receiver and records authentication failures from both the ``AxesBackend`` and other authentication backends and tracks the failed attempts by tracking the attempt IP address, username, user agent, or all of them. If the lockout rules match, then Axes marks the request as locked by setting a special attribute into the request. The ``AxesMiddleware`` then processes the request, returning a lockout response to the user, if the flag has been set. Axes assumes that the login views either call the ``authenticate`` method to log in users or otherwise take care of notifying Axes of authentication attempts and failures the same way Django does via authentication signals. The login flows can be customized and the Axes authentication backend, middleware, and signal receivers can easily be swapped to alternative implementations. django-axes-5.0.7/docs/conf.py0000644000175000017500000001000113500726664015133 0ustar jamesjames""" Sphinx documentation generator configuration. More information on the configuration options is available at: http://www.sphinx-doc.org/en/master/usage/configuration.html """ import os import axes import django import sphinx_rtd_theme os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'axes.tests.settings') django.setup() # -- Extra custom configuration ------------------------------------------ title = 'Django Axes Documentation' description = 'Keep track of failed login attempts in Django-powered sites.', # -- General configuration ------------------------------------------------ # Add any Sphinx extension module names here, as strings. # They can be extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = ['sphinx.ext.autodoc'] # Add any paths that contain templates here, relative to this directory. templates_path = [ '_templates', ] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: source_suffix = ['.rst', '.md'] source_suffix = '.rst' # The master toctree document. master_doc = 'index' # General information about the project. project = 'Django Axes' copyright = '2016, Jazzband' author = 'Jazzband' # The short X.Y version. version = axes.get_version() # The full version, including alpha/beta/rc tags. release = axes.get_version() # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = None # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = [ '_build', ] # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'sphinx_rtd_theme' # Add any paths that contain custom themes here, relative to this directory. html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Custom sidebar templates, maps document names to template names. html_sidebars = { '**': [ 'globaltoc.html', 'relations.html', 'sourcelink.html', 'searchbox.html', ], } # Output file base name for HTML help builder. htmlhelp_basename = 'DjangoAxesdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { 'papersize': 'a4paper', 'pointsize': '12pt', 'preamble': '', 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto, manual, or own class]). latex_documents = [ ( master_doc, 'DjangoAxes.tex', title, author, 'manual', ), ] # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ( master_doc, 'djangoaxes', description, [author], 1, ), ] # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, dir menu entry, description, category) texinfo_documents = [ ( master_doc, 'DjangoAxes', title, author, 'DjangoAxes', description, 'Miscellaneous', ), ] django-axes-5.0.7/docs/10_changelog.rst0000644000175000017500000000005313500726664016623 0ustar jamesjames.. changelog: .. include:: ../CHANGES.rst django-axes-5.0.7/docs/6_integration.rst0000644000175000017500000002017613500726664017154 0ustar jamesjames.. _integration: Integration =========== Axes is intended to be pluggable and usable with custom authentication solutions. This document describes the integration with some popular 3rd party packages such as Django Allauth, Django REST Framework, and other tools. In the following table **Compatible** means that a component should be fully compatible out-of-the-box, **Functional** means that a component should be functional after customization, and **Incompatible** means that a component has been reported as non-functional with Axes. ======================= ============= ============ ============ ============== Project Version Compatible Functional Incompatible ======================= ============= ============ ============ ============== Django REST Framework |gte| 3.7.0 |check| Django REST Framework |lt| 3.7.0 |check| Django Allauth |check| Django Simple Captcha |check| Django OAuth Toolkit |check| ======================= ============= ============ ============ ============== .. |check| unicode:: U+2713 .. |lt| unicode:: U+003C .. |lte| unicode:: U+2264 .. |gte| unicode:: U+2265 .. |gt| unicode:: U+003E Please note that project compatibility depends on multiple different factors such as Django version, Axes version, and 3rd party package versions and their unique combinations per project. .. note:: This documentation is mostly provided by Axes users. If you have your own compatibility tweaks and customizations that enable you to use Axes with other tools or have better implementations than the solutions provided here, please do feel free to open an issue or a pull request in GitHub! Integration with Django Allauth ------------------------------- Axes relies on having login information stored under ``AXES_USERNAME_FORM_FIELD`` key both in ``request.POST`` and in ``credentials`` dict passed to ``user_login_failed`` signal. This is not the case with Allauth. Allauth always uses the ``login`` key in post POST data but it becomes ``username`` key in ``credentials`` dict in signal handler. To overcome this you need to use custom login form that duplicates the value of ``username`` key under a ``login`` key in that dict and set ``AXES_USERNAME_FORM_FIELD = 'login'``. You also need to decorate ``dispatch()`` and ``form_invalid()`` methods of the Allauth login view. ``settings.py``:: AXES_USERNAME_FORM_FIELD = 'login' ``example/forms.py``:: from allauth.account.forms import LoginForm class AxesLoginForm(LoginForm): """ Extended login form class that supplied the user credentials for Axes compatibility. """ def user_credentials(self): credentials = super().user_credentials() credentials['login'] = credentials.get('email') or credentials.get('username') return credentials ``example/urls.py``:: from django.utils.decorators import method_decorator from allauth.account.views import LoginView from axes.decorators import axes_dispatch from axes.decorators import axes_form_invalid from example.forms import AxesLoginForm LoginView.dispatch = method_decorator(axes_dispatch)(LoginView.dispatch) LoginView.form_invalid = method_decorator(axes_form_invalid)(LoginView.form_invalid) urlpatterns = [ # Override allauth default login view with a patched view url(r'^accounts/login/$', LoginView.as_view(form_class=AxesLoginForm), name='account_login'), url(r'^accounts/', include('allauth.urls')), ] Integration with Django REST Framework -------------------------------------- .. note:: Modern versions of Django REST Framework after 3.7.0 work normally with Axes out-of-the-box and require no customization in DRF. Django REST Framework versions prior to 3.7.0 require the request object to be passed for authentication by a customized DRF authentication class:: from rest_framework.authentication import BasicAuthentication class AxesBasicAuthentication(BasicAuthentication): """ Extended basic authentication backend class that supplies the request object into the authentication call for Axes compatibility. NOTE: This patch is only needed for DRF versions < 3.7.0. """ def authenticate(self, request): # NOTE: Request is added as an instance attribute in here self._current_request = request return super().authenticate(request) def authenticate_credentials(self, userid, password, request=None): credentials = { get_user_model().USERNAME_FIELD: userid, 'password': password } # NOTE: Request is added as an argument to the authenticate call here user = authenticate(request=request or self._current_request, **credentials) if user is None: raise exceptions.AuthenticationFailed(_('Invalid username/password.')) if not user.is_active: raise exceptions.AuthenticationFailed(_('User inactive or deleted.')) return (user, None) Integration with Django Simple Captcha -------------------------------------- Axes supports Captcha with the Django Simple Captcha package in the following manner. ``settings.py``:: AXES_LOCKOUT_URL = '/locked' ``example/urls.py``:: url(r'^locked/$', locked_out, name='locked_out'), ``example/forms.py``:: class AxesCaptchaForm(forms.Form): captcha = CaptchaField() ``example/views.py``:: from example.forms import AxesCaptchaForm def locked_out(request): if request.POST: form = AxesCaptchaForm(request.POST) if form.is_valid(): ip = get_ip_address_from_request(request) reset(ip=ip) return HttpResponseRedirect(reverse_lazy('signin')) else: form = AxesCaptchaForm() return render_to_response('captcha.html', dict(form=form), context_instance=RequestContext(request)) ``example/templates/example/captcha.html``::
{% csrf_token %} {{ form.captcha.errors }} {{ form.captcha }}
Integration with Django OAuth Toolkit ------------------------------------- Django OAuth toolkit is not designed to work with Axes, but some users have reported that they have configured validator classes to function correctly. ``example/validators.py``:: from django.contrib.auth import authenticate from django.http import HttpRequest, QueryDict from oauth2_provider.oauth2_validators import OAuth2Validator class AxesOAuth2Validator(OAuth2Validator): def validate_user(self, username, password, client, request, *args, **kwargs): """ Set defaults for necessary request object attributes for Axes compatibility. The ``request`` argument is not a Django ``HttpRequest`` object. """ _request = HttpRequest() _request.decoded_body = request.decoded_body _request.headers = request.headers _request.http_method = request.http_method _request.uri = request.uri _request._params = request._params _request.method = _request.http_method _request.META = _request.headers _body = QueryDict(str(request.body), mutable=True) if _request.method == 'GET': _request.GET = _body elif _request.method == 'POST': _request.POST = _body u = authenticate(request=_request, username=username, password=password) if u is not None and u.is_active: request.user = u return True return False ``settings.py``:: OAUTH2_PROVIDER = { 'OAUTH2_VALIDATOR_CLASS': 'example.validators.AxesOAuth2Validator', 'SCOPES': {'read': 'Read scope', 'write': 'Write scope'}, } django-axes-5.0.7/docs/index.rst0000644000175000017500000000054313500726664015507 0ustar jamesjames.. _index: Django Axes Documentation ========================= Contents -------- .. toctree:: :maxdepth: 2 :numbered: 1 1_requirements 2_installation 3_usage 4_configuration 5_customization 6_integration 7_architecture 8_reference 9_development 10_changelog Indices and tables ------------------ * :ref:`search` django-axes-5.0.7/docs/Makefile0000644000175000017500000001640113500726664015306 0ustar jamesjames# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " applehelp to make an Apple Help Book" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" @echo " coverage to run coverage check of the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoAxes.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoAxes.qhc" applehelp: $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp @echo @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." @echo "N.B. You won't be able to view it unless you put it in" \ "~/Library/Documentation/Help or install it in your application" \ "bundle." devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoAxes" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoAxes" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." coverage: $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage @echo "Testing of coverage in the sources finished, look at the " \ "results in $(BUILDDIR)/coverage/python.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." django-axes-5.0.7/docs/5_customization.rst0000644000175000017500000001211713500726664017534 0ustar jamesjames.. customization: Customization ============= Axes has multiple options for customization including customizing the attempt tracking and lockout handling logic and lockout response formatting. There are public APIs and the whole Axes tracking system is pluggable. You can swap the authentication backend, attempt tracker, failure handlers, database or cache backends and error formatters as you see fit. Check the API reference section for further inspiration on implementing custom authentication backends, middleware, and handlers. Axes uses the stock Django signals for login monitoring and can be customized and extended by using them correctly. Axes listens to the following signals from ``django.contrib.auth.signals`` to log access attempts: * ``user_logged_in`` * ``user_logged_out`` * ``user_login_failed`` You can also use Axes with your own auth module, but you'll need to ensure that it sends the correct signals in order for Axes to log the access attempts. Customizing authentication views -------------------------------- Here is a more detailed example of sending the necessary signals using and a custom auth backend at an endpoint that expects JSON requests. The custom authentication can be swapped out with ``authenticate`` and ``login`` from ``django.contrib.auth``, but beware that those methods take care of sending the nessary signals for you, and there is no need to duplicate them as per the example. ``example/forms.py``:: from django import forms class LoginForm(forms.Form): username = forms.CharField(max_length=128, required=True) password = forms.CharField(max_length=128, required=True) ``example/views.py``:: from django.contrib.auth import signals from django.http import JsonResponse, HttpResponse from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.csrf import csrf_exempt from axes.decorators import axes_dispatch from example.forms import LoginForm from example.authentication import authenticate, login @method_decorator(axes_dispatch, name='dispatch') @method_decorator(csrf_exempt, name='dispatch') class Login(View): """ Custom login view that takes JSON credentials """ http_method_names = ['post'] def post(self, request): form = LoginForm(request.POST) if not form.is_valid(): # inform django-axes of failed login signals.user_login_failed.send( sender=User, request=request, credentials={ 'username': form.cleaned_data.get('username'), }, ) return HttpResponse(status=400) user = authenticate( request=request, username=form.cleaned_data.get('username'), password=form.cleaned_data.get('password'), ) if user is not None: login(request, user) signals.user_logged_in.send( sender=User, request=request, user=user, ) return JsonResponse({ 'message':'success' }, status=200) # inform django-axes of failed login signals.user_login_failed.send( sender=User, request=request, credentials={ 'username': form.cleaned_data.get('username'), }, ) return HttpResponse(status=403) ``urls.py``:: from django.urls import path from example.views import Login urlpatterns = [ path('login/', Login.as_view(), name='login'), ] Customizing username lookups ---------------------------- In special cases, you may have the need to modify the username that is submitted before attempting to authenticate. For example, adding namespacing or removing client-set prefixes. In these cases, ``axes`` needs to know how to make these changes so that it can correctly identify the user without any form cleaning or validation. This is where the ``AXES_USERNAME_CALLABLE`` setting comes in. You can define how to make these modifications in a callable that takes a request object and a credentials dictionary, and provide that callable to ``axes`` via this setting. For example, a function like this could take a post body with something like ``username='prefixed-username'`` and ``namespace=my_namespace`` and turn it into ``my_namespace-username``: ``example/utils.py``:: def get_username(request, credentials): username = credentials.get('username') namespace = credentials.get('namespace') return namespace + '-' + username ``settings.py``:: AXES_USERNAME_CALLABLE = 'example.utils.get_username' .. note:: You still have to make these modifications yourself before calling authenticate. If you want to re-use the same function for consistency, that's fine, but Axes does not inject these changes into the authentication flow for you. django-axes-5.0.7/docs/9_development.rst0000644000175000017500000000256013500726664017153 0ustar jamesjames.. _development: Development =========== You can contribute to this project forking it from GitHub and sending pull requests. First `fork `_ the `repository `_ and then clone it:: $ git clone git@github.com:/django-axes.git Initialize a virtual environment for development purposes:: $ mkdir -p ~/.virtualenvs $ python3 -m venv ~/.virtualenvs/django-axes $ source ~/.virtualenvs/django-axes/bin/activate Then install the necessary requirements:: $ cd django-axes $ pip install -r requirements.txt Unit tests are located in the ``axes/tests`` folder and can be easily run with the pytest tool:: $ pytest Prospector runs a number of source code style, safety, and complexity checks:: $ prospector Mypy runs static typing checks to verify the source code type annotations and correctness:: $ mypy . Before committing, you can run all the above tests against all supported Python and Django versions with tox:: $ tox Tox runs the same test set that is run by Travis, and your code should be good to go if it passes. If you wish to limit the testing to specific environment(s), you can parametrize the tox run:: $ tox -e py37-django21 After you have pushed your changes, open a pull request on GitHub for getting your code upstreamed. django-axes-5.0.7/docs/3_usage.rst0000644000175000017500000001007213500726664015724 0ustar jamesjames.. _usage: Usage ===== Once Axes is is installed and configured, you can login and logout of your application via the ``django.contrib.auth`` views. The attempts will be logged and visible in the Access Attempts section in admin. Axes monitors the views by using the Django login and logout signals and locks out user attempts with a custom authentication backend that checks if requests are allowed to authenticate per the configured rules. By default, Axes will lock out repeated access attempts from the same IP address by monitoring login failures and storing them into the default database. Authenticating users -------------------- Axes needs a ``request`` attribute to be supplied to the stock Django ``authenticate`` method in the ``django.contrib.auth`` module in order to function correctly. If you wish to manually supply the argument to the calls to ``authenticate``, you can use the following snippet in your custom login views, tests, or other code:: def custom_login_view(request) username = ... password = ... user = authenticate( request=request, # this is the important custom argument username=username, password=password, ) if user is not None: login(request, user) If your test setup has problems with the ``request`` argument, you can either supply the argument manually with a blank `HttpRequest()`` object, disable Axes in the test setup by excluding ``axes`` from ``INSTALLED_APPS``, or leave out ``axes.backends.AxesBackend`` from your ``AUTHENTICATION_BACKENDS``. If you are using a 3rd party library that does not supply the ``request`` attribute when calling ``authenticate`` you can implement a customized backend that inherits from ``axes.backends.AxesBackend`` or other backend and overrides the ``authenticate`` method. Resetting attempts and lockouts ------------------------------- When Axes locks an IP address, it is not allowed to login again. You can allow IPs to attempt again by resetting (deleting) the relevant AccessAttempt records in the admin UI, CLI, or your own code. You can also configure automatic cool down periods, IP whitelists, and custom code and handler functions for resetting attempts. Please check out the configuration and customization documentation for further information. .. note:: Please note that the functionality describe here concerns the default database handler. If you have changed the default handler to another class such as the cache handler you have to implement custom reset commands. Resetting attempts from the Django admin UI ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Records can be easily deleted by using the Django admin application. Go to the admin UI and check the ``Access Attempt`` view. Select the attempts you wish the allow again and simply remove them. The blocked user will be allowed to log in again in accordance to the rules. Resetting attempts from command line ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Axes offers a command line interface with ``axes_reset``, ``axes_reset_ip``, and ``axes_reset_username`` management commands with the Django ``manage.py`` or ``django-admin`` command helpers: - ``python manage.py axes_reset`` will reset all lockouts and access records. - ``python manage.py axes_reset_ip [ip ...]`` will clear lockouts and records for the given IP addresses. - ``python manage.py axes_reset_username [username ...]`` will clear lockouts and records for the given usernames. Resetting attempts programmatically by APIs ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ In your code, you can use the ``axes.utils.reset`` function. - ``reset()`` will reset all lockouts and access records. - ``reset(ip=ip)`` will clear lockouts and records for the given IP address. - ``reset(username=username)`` will clear lockouts and records for the given username. .. note:: Please note that if you give both ``username`` and ``ip`` arguments to ``reset`` that attempts that have both the set IP and username are reset. The effective behaviour of ``reset`` is to ``and`` the terms instead of ``or`` ing them. django-axes-5.0.7/.pyup.yml0000644000175000017500000000017713500726664014517 0ustar jamesjames# autogenerated pyup.io config file # see https://pyup.io/docs/configuration/ for all available options schedule: every week django-axes-5.0.7/.prospector.yaml0000644000175000017500000000012613500726664016055 0ustar jamesjamesignore-paths: - docs - axes/migrations pep8: options: max-line-length: 119 django-axes-5.0.7/axes/0000755000175000017500000000000013500726664013654 5ustar jamesjamesdjango-axes-5.0.7/axes/models.py0000644000175000017500000000316213500726664015513 0ustar jamesjamesfrom django.db import models from django.utils.translation import gettext_lazy as _ class AccessBase(models.Model): user_agent = models.CharField( _('User Agent'), max_length=255, db_index=True, ) ip_address = models.GenericIPAddressField( _('IP Address'), null=True, db_index=True, ) username = models.CharField( _('Username'), max_length=255, null=True, db_index=True, ) http_accept = models.CharField( _('HTTP Accept'), max_length=1025, ) path_info = models.CharField( _('Path'), max_length=255, ) attempt_time = models.DateTimeField( _('Attempt Time'), auto_now_add=True, ) class Meta: app_label = 'axes' abstract = True ordering = ['-attempt_time'] class AccessAttempt(AccessBase): get_data = models.TextField( _('GET Data'), ) post_data = models.TextField( _('POST Data'), ) failures_since_start = models.PositiveIntegerField( _('Failed Logins'), ) def __str__(self): return f'Attempted Access: {self.attempt_time}' class Meta: verbose_name = _('access attempt') verbose_name_plural = _('access attempts') class AccessLog(AccessBase): logout_time = models.DateTimeField( _('Logout Time'), null=True, blank=True, ) def __str__(self): return f'Access Log for {self.username} @ {self.attempt_time}' class Meta: verbose_name = _('access log') verbose_name_plural = _('access logs') django-axes-5.0.7/axes/checks.py0000644000175000017500000000715013500726664015471 0ustar jamesjamesfrom django.core.checks import Tags, Warning, register # pylint: disable=redefined-builtin from axes.conf import settings class Messages: CACHE_INVALID = ( "You are using the django-axes cache handler for login attempt tracking." " Your cache configuration is however invalid and will not work correctly with django-axes." " This can leave security holes in your login systems as attempts are not tracked correctly." " Reconfigure settings.AXES_CACHE and settings.CACHES per django-axes configuration documentation." ) MIDDLEWARE_INVALID = ( "You do not have 'axes.middleware.AxesMiddleware' in your settings.MIDDLEWARE." ) BACKEND_INVALID = ( "You do not have 'axes.backends.AxesBackend' in your settings.AUTHENTICATION_BACKENDS." ) SETTING_DEPRECATED = ( 'You have a deprecated setting {deprecated_setting} configured in your project settings' ) class Hints: CACHE_INVALID = None MIDDLEWARE_INVALID = None BACKEND_INVALID = 'AxesModelBackend was renamed to AxesBackend in django-axes version 5.0.' SETTING_DEPRECATED = None class Codes: CACHE_INVALID = 'axes.W001' MIDDLEWARE_INVALID = 'axes.W002' BACKEND_INVALID = 'axes.W003' SETTING_DEPRECATED = 'axes.W004' @register(Tags.security, Tags.caches, Tags.compatibility) def axes_cache_check(app_configs, **kwargs): # pylint: disable=unused-argument axes_handler = getattr(settings, 'AXES_HANDLER', '') axes_cache_key = getattr(settings, 'AXES_CACHE', 'default') axes_cache_config = settings.CACHES.get(axes_cache_key, {}) axes_cache_backend = axes_cache_config.get('BACKEND', '') axes_cache_backend_incompatible = [ 'django.core.cache.backends.dummy.DummyCache', 'django.core.cache.backends.locmem.LocMemCache', 'django.core.cache.backends.filebased.FileBasedCache', ] warnings = [] if axes_handler == 'axes.handlers.cache.AxesCacheHandler': if axes_cache_backend in axes_cache_backend_incompatible: warnings.append(Warning( msg=Messages.CACHE_INVALID, hint=Hints.CACHE_INVALID, id=Codes.CACHE_INVALID, )) return warnings @register(Tags.security, Tags.compatibility) def axes_middleware_check(app_configs, **kwargs): # pylint: disable=unused-argument warnings = [] if 'axes.middleware.AxesMiddleware' not in settings.MIDDLEWARE: warnings.append(Warning( msg=Messages.MIDDLEWARE_INVALID, hint=Hints.MIDDLEWARE_INVALID, id=Codes.MIDDLEWARE_INVALID, )) return warnings @register(Tags.security, Tags.compatibility) def axes_backend_check(app_configs, **kwargs): # pylint: disable=unused-argument warnings = [] if 'axes.backends.AxesBackend' not in settings.AUTHENTICATION_BACKENDS: warnings.append(Warning( msg=Messages.BACKEND_INVALID, hint=Hints.BACKEND_INVALID, id=Codes.BACKEND_INVALID, )) return warnings @register(Tags.compatibility) def axes_deprecation_check(app_configs, **kwargs): # pylint: disable=unused-argument warnings = [] deprecated_settings = [ 'AXES_DISABLE_SUCCESS_ACCESS_LOG', ] for deprecated_setting in deprecated_settings: try: getattr(settings, deprecated_setting) warnings.append(Warning( msg=Messages.SETTING_DEPRECATED.format(deprecated_setting=deprecated_setting), hint=None, id=Codes.SETTING_DEPRECATED, )) except AttributeError: pass return warnings django-axes-5.0.7/axes/locale/0000755000175000017500000000000013500726664015113 5ustar jamesjamesdjango-axes-5.0.7/axes/locale/de/0000755000175000017500000000000013500726664015503 5ustar jamesjamesdjango-axes-5.0.7/axes/locale/de/LC_MESSAGES/0000755000175000017500000000000013500726664017270 5ustar jamesjamesdjango-axes-5.0.7/axes/locale/de/LC_MESSAGES/django.po0000644000175000017500000000411213500726664021070 0ustar jamesjames# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-07-17 15:56+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: axes/admin.py:38 msgid "Form Data" msgstr "Form-Daten" #: axes/admin.py:41 axes/admin.py:95 msgid "Meta Data" msgstr "Meta-Daten" #: axes/conf.py:58 msgid "Account locked: too many login attempts. Please try again later" msgstr "" "Zugang gesperrt: zu viele fehlgeschlagene Anmeldeversuche. Bitte versuchen " "Sie es später erneut." #: axes/conf.py:61 msgid "" "Account locked: too many login attempts. Contact an admin to unlock your " "account." msgstr "" "Zugang gesperrt: zu viele fehlgeschlagene Anmeldeversuche. Kontaktieren Sie " "einen Administrator, um Ihren Zugang zu entsperren." #: axes/models.py:9 msgid "User Agent" msgstr "Browserkennung" #: axes/models.py:15 msgid "IP Address" msgstr "IP-Adresse" #: axes/models.py:21 msgid "Username" msgstr "Benutzername" #: axes/models.py:35 msgid "HTTP Accept" msgstr "" #: axes/models.py:40 msgid "Path" msgstr "Pfad" #: axes/models.py:45 msgid "Attempt Time" msgstr "Zugriffszeitpunkt" #: axes/models.py:57 msgid "GET Data" msgstr "GET-Daten" #: axes/models.py:61 msgid "POST Data" msgstr "POST-Daten" #: axes/models.py:65 msgid "Failed Logins" msgstr "Fehlgeschlagene Anmeldeversuche" #: axes/models.py:76 msgid "access attempt" msgstr "Zugriffsversuch" #: axes/models.py:77 msgid "access attempts" msgstr "Zugriffsversuche" #: axes/models.py:81 msgid "Logout Time" msgstr "Abmeldezeitpunkt" #: axes/models.py:90 msgid "access log" msgstr "Zugriffslog" #: axes/models.py:91 msgid "access logs" msgstr "Zugriffslogs" django-axes-5.0.7/axes/locale/de/LC_MESSAGES/django.mo0000644000175000017500000000301513500726664021066 0ustar jamesjames<Q? C P ^h q |     kWa9K k v         Account locked: too many login attempts. Contact an admin to unlock your account.Account locked: too many login attempts. Please try again laterAttempt TimeFailed LoginsForm DataGET DataIP AddressLogout TimeMeta DataPOST DataPathUser AgentUsernameaccess attemptaccess attemptsaccess logaccess logsProject-Id-Version: PACKAGE VERSION Report-Msgid-Bugs-To: POT-Creation-Date: 2018-07-17 15:56+0200 PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE Last-Translator: FULL NAME Language-Team: LANGUAGE Language: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); Zugang gesperrt: zu viele fehlgeschlagene Anmeldeversuche. Kontaktieren Sie einen Administrator, um Ihren Zugang zu entsperren.Zugang gesperrt: zu viele fehlgeschlagene Anmeldeversuche. Bitte versuchen Sie es später erneut.ZugriffszeitpunktFehlgeschlagene AnmeldeversucheForm-DatenGET-DatenIP-AdresseAbmeldezeitpunktMeta-DatenPOST-DatenPfadBrowserkennungBenutzernameZugriffsversuchZugriffsversucheZugriffslogZugriffslogsdjango-axes-5.0.7/axes/locale/ru/0000755000175000017500000000000013500726664015541 5ustar jamesjamesdjango-axes-5.0.7/axes/locale/ru/LC_MESSAGES/0000755000175000017500000000000013500726664017326 5ustar jamesjamesdjango-axes-5.0.7/axes/locale/ru/LC_MESSAGES/django.po0000644000175000017500000000467613500726664021145 0ustar jamesjames# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2019-01-11 12:20+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: axes/admin.py:38 msgid "Form Data" msgstr "Данные формы" #: axes/admin.py:41 axes/admin.py:95 msgid "Meta Data" msgstr "Метаданные" #: axes/conf.py:58 msgid "Account locked: too many login attempts. Please try again later" msgstr "" "Учетная запись заблокирована: слишком много попыток входа. " "Повторите попытку позже." #: axes/conf.py:61 msgid "" "Account locked: too many login attempts. Contact an admin to unlock your " "account." msgstr "" "Учетная запись заблокирована: слишком много попыток входа. " "Обратитесь к администратору для разблокирования учетной записи." #: axes/models.py:9 msgid "User Agent" msgstr "Браузер пользователя" #: axes/models.py:15 msgid "IP Address" msgstr "Адрес IP" #: axes/models.py:21 msgid "Username" msgstr "Пользователь" #: axes/models.py:35 msgid "HTTP Accept" msgstr "Запрос HTTP" #: axes/models.py:40 msgid "Path" msgstr "Путь" #: axes/models.py:45 msgid "Attempt Time" msgstr "Время входа" #: axes/models.py:57 msgid "GET Data" msgstr "Данные GET-запроса" #: axes/models.py:61 msgid "POST Data" msgstr "Данные POST-запроса" #: axes/models.py:65 msgid "Failed Logins" msgstr "Ошибочные попытки" #: axes/models.py:76 msgid "access attempt" msgstr "Запись о попытке доступа" #: axes/models.py:77 msgid "access attempts" msgstr "Попытки доступа" #: axes/models.py:81 msgid "Logout Time" msgstr "Время выхода" #: axes/models.py:90 msgid "access log" msgstr "Запись о доступе" #: axes/models.py:91 msgid "access logs" msgstr "Логи доступа" django-axes-5.0.7/axes/locale/ru/LC_MESSAGES/django.mo0000644000175000017500000000363613500726664021135 0ustar jamesjamesLQ? S ` nx        ksX! +Cc u '-Ig   Account locked: too many login attempts. Contact an admin to unlock your account.Account locked: too many login attempts. Please try again laterAttempt TimeFailed LoginsForm DataGET DataHTTP AcceptIP AddressLogout TimeMeta DataPOST DataPathUser AgentUsernameaccess attemptaccess attemptsaccess logaccess logsProject-Id-Version: PACKAGE VERSION Report-Msgid-Bugs-To: POT-Creation-Date: 2019-01-11 12:20+0300 PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE Last-Translator: FULL NAME Language-Team: LANGUAGE Language: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); Учетная запись заблокирована: слишком много попыток входа. Обратитесь к администратору для разблокирования учетной записи.Учетная запись заблокирована: слишком много попыток входа. Повторите попытку позже.Время входаОшибочные попыткиДанные формыДанные GET-запросаЗапрос HTTPАдрес IPВремя выходаМетаданныеДанные POST-запросаПутьБраузер пользователяПользовательЗапись о попытке доступаПопытки доступаЗапись о доступеЛоги доступаdjango-axes-5.0.7/axes/locale/tr/0000755000175000017500000000000013500726664015540 5ustar jamesjamesdjango-axes-5.0.7/axes/locale/tr/LC_MESSAGES/0000755000175000017500000000000013500726664017325 5ustar jamesjamesdjango-axes-5.0.7/axes/locale/tr/LC_MESSAGES/django.po0000644000175000017500000000403013500726664021124 0ustar jamesjames# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-07-17 15:56+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: axes/admin.py:38 msgid "Form Data" msgstr "Form-Verisi" #: axes/admin.py:41 axes/admin.py:95 msgid "Meta Data" msgstr "Meta-Verisi" #: axes/conf.py:58 msgid "Account locked: too many login attempts. Please try again later" msgstr "" "Hesap kilitlendi: cok fazla erişim denemesi. Lütfen daha sonra tekrar deneyiniz" #: axes/conf.py:61 msgid "" "Account locked: too many login attempts. Contact an admin to unlock your " "account." msgstr "" "Hesap kilitlendi: cok fazla erişim denemesi. Hesabını açtırmak için yöneticiyle iletişime" "geçin" #: axes/models.py:9 msgid "User Agent" msgstr "" #: axes/models.py:15 msgid "IP Address" msgstr "IP-Adresi" #: axes/models.py:21 msgid "Username" msgstr "Kullanıcı Adı" #: axes/models.py:35 msgid "HTTP Accept" msgstr "" #: axes/models.py:40 msgid "Path" msgstr "Yol" #: axes/models.py:45 msgid "Attempt Time" msgstr "Girişim Zamanı" #: axes/models.py:57 msgid "GET Data" msgstr "GET-Verisi" #: axes/models.py:61 msgid "POST Data" msgstr "POST-Verisi" #: axes/models.py:65 msgid "Failed Logins" msgstr "Geçersiz Girişler" #: axes/models.py:76 msgid "access attempt" msgstr "erişim denemesi" #: axes/models.py:77 msgid "access attempts" msgstr "erişim denemeleri" #: axes/models.py:81 msgid "Logout Time" msgstr "Çıkış Zamanı" #: axes/models.py:90 msgid "access log" msgstr "erişim kaydı" #: axes/models.py:91 msgid "access logs" msgstr "erişim kayıtları" django-axes-5.0.7/axes/locale/tr/LC_MESSAGES/django.mo0000644000175000017500000000265213500726664021131 0ustar jamesjames,Q?  ( 6@ I T ` jty  kg$Q   $ 6 BNRct   Account locked: too many login attempts. Contact an admin to unlock your account.Account locked: too many login attempts. Please try again laterAttempt TimeFailed LoginsForm DataGET DataIP AddressLogout TimeMeta DataPOST DataPathUsernameaccess attemptaccess attemptsaccess logaccess logsProject-Id-Version: PACKAGE VERSION Report-Msgid-Bugs-To: POT-Creation-Date: 2018-07-17 15:56+0200 PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE Last-Translator: FULL NAME Language-Team: LANGUAGE Language: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); Hesap kilitlendi: cok fazla erişim denemesi. Hesabını açtırmak için yöneticiyle iletişimegeçinHesap kilitlendi: cok fazla erişim denemesi. Lütfen daha sonra tekrar deneyinizGirişim ZamanıGeçersiz GirişlerForm-VerisiGET-VerisiIP-AdresiÇıkış ZamanıMeta-VerisiPOST-VerisiYolKullanıcı Adıerişim denemesierişim denemelerierişim kaydıerişim kayıtlarıdjango-axes-5.0.7/axes/attempts.py0000644000175000017500000000664413500726664016101 0ustar jamesjamesfrom logging import getLogger from django.contrib.auth import get_user_model from django.db.models import QuerySet from django.utils.timezone import datetime, now from axes.conf import settings from axes.models import AccessAttempt from axes.helpers import ( get_client_username, get_client_parameters, get_cool_off, ) log = getLogger(settings.AXES_LOGGER) def get_cool_off_threshold(attempt_time: datetime = None) -> datetime: """ Get threshold for fetching access attempts from the database. """ cool_off = get_cool_off() if cool_off is None: raise TypeError('Cool off threshold can not be calculated with settings.AXES_COOLOFF_TIME set to None') if attempt_time is None: return now() - cool_off return attempt_time - cool_off def filter_user_attempts(request, credentials: dict = None) -> QuerySet: """ Return a queryset of AccessAttempts that match the given request and credentials. """ username = get_client_username(request, credentials) filter_kwargs = get_client_parameters(username, request.axes_ip_address, request.axes_user_agent) return AccessAttempt.objects.filter(**filter_kwargs) def get_user_attempts(request, credentials: dict = None) -> QuerySet: """ Get valid user attempts that match the given request and credentials. """ attempts = filter_user_attempts(request, credentials) if settings.AXES_COOLOFF_TIME is None: log.debug('AXES: Getting all access attempts from database because no AXES_COOLOFF_TIME is configured') return attempts threshold = get_cool_off_threshold(request.axes_attempt_time) log.debug('AXES: Getting access attempts that are newer than %s', threshold) return attempts.filter(attempt_time__gte=threshold) def clean_expired_user_attempts(attempt_time: datetime = None) -> int: """ Clean expired user attempts from the database. """ if settings.AXES_COOLOFF_TIME is None: log.debug('AXES: Skipping clean for expired access attempts because no AXES_COOLOFF_TIME is configured') return 0 threshold = get_cool_off_threshold(attempt_time) count, _ = AccessAttempt.objects.filter(attempt_time__lt=threshold).delete() log.info('AXES: Cleaned up %s expired access attempts from database that were older than %s', count, threshold) return count def reset_user_attempts(request, credentials: dict = None) -> int: """ Reset all user attempts that match the given request and credentials. """ attempts = filter_user_attempts(request, credentials) count, _ = attempts.delete() log.info('AXES: Reset %s access attempts from database.', count) return count def is_user_attempt_whitelisted(request, credentials: dict = None) -> bool: """ Check if the given request or credentials refer to a whitelisted username. A whitelisted user has the magic ``nolockout`` property set. If the property is unknown or False or the user can not be found, this implementation fails gracefully and returns True. """ username_field = getattr(get_user_model(), 'USERNAME_FIELD', 'username') username_value = get_client_username(request, credentials) kwargs = { username_field: username_value } user_model = get_user_model() try: user = user_model.objects.get(**kwargs) return user.nolockout except (user_model.DoesNotExist, AttributeError): pass return False django-axes-5.0.7/axes/signals.py0000644000175000017500000000313313500726664015666 0ustar jamesjamesfrom logging import getLogger from django.contrib.auth.signals import user_logged_in, user_logged_out, user_login_failed from django.core.signals import setting_changed from django.db.models.signals import post_save, post_delete from django.dispatch import receiver from django.dispatch import Signal from axes.conf import settings from axes.models import AccessAttempt from axes.handlers.proxy import AxesProxyHandler log = getLogger(settings.AXES_LOGGER) user_locked_out = Signal(providing_args=['request', 'username', 'ip_address']) @receiver(user_login_failed) def handle_user_login_failed(*args, **kwargs): AxesProxyHandler.user_login_failed(*args, **kwargs) @receiver(user_logged_in) def handle_user_logged_in(*args, **kwargs): AxesProxyHandler.user_logged_in(*args, **kwargs) @receiver(user_logged_out) def handle_user_logged_out(*args, **kwargs): AxesProxyHandler.user_logged_out(*args, **kwargs) @receiver(post_save, sender=AccessAttempt) def handle_post_save_access_attempt(*args, **kwargs): AxesProxyHandler.post_save_access_attempt(*args, **kwargs) @receiver(post_delete, sender=AccessAttempt) def handle_post_delete_access_attempt(*args, **kwargs): AxesProxyHandler.post_delete_access_attempt(*args, **kwargs) @receiver(setting_changed) def handle_setting_changed(sender, setting, value, enter, **kwargs): # pylint: disable=unused-argument """ Reinitialize handler implementation if a relevant setting changes in e.g. application reconfiguration or during testing. """ if setting == 'AXES_HANDLER': AxesProxyHandler.get_implementation(force=True) django-axes-5.0.7/axes/apps.py0000644000175000017500000000231513500726664015172 0ustar jamesjamesfrom logging import getLogger from django import apps from axes import get_version from axes.conf import settings log = getLogger(settings.AXES_LOGGER) class AppConfig(apps.AppConfig): name = 'axes' logging_initialized = False @classmethod def initialize(cls): """ Initialize Axes logging and show version information. This method is re-entrant and can be called multiple times. It displays version information exactly once at application startup. """ if not settings.AXES_ENABLED: return if not settings.AXES_VERBOSE: return if cls.logging_initialized: return cls.logging_initialized = True log.info('AXES: BEGIN LOG') log.info('AXES: Using django-axes %s', get_version()) if settings.AXES_ONLY_USER_FAILURES: log.info('AXES: blocking by username only.') elif settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP: log.info('AXES: blocking by combination of username and IP.') else: log.info('AXES: blocking by IP only.') def ready(self): self.initialize() from axes import checks, signals # noqa django-axes-5.0.7/axes/conf.py0000644000175000017500000000543413500726664015161 0ustar jamesjamesfrom django.conf import settings from django.utils.translation import gettext_lazy as _ from appconf import AppConf class AxesAppConf(AppConf): # disable plugin when set to False ENABLED = True # see if the user has overridden the failure limit FAILURE_LIMIT = 3 # see if the user has set axes to lock out logins after failure limit LOCK_OUT_AT_FAILURE = True # lock out with the combination of username and IP address LOCK_OUT_BY_COMBINATION_USER_AND_IP = False # lock out with username and never the IP or user agent ONLY_USER_FAILURES = False # lock out with the user agent, has no effect when ONLY_USER_FAILURES is set USE_USER_AGENT = False # use a specific username field to retrieve from login POST data USERNAME_FORM_FIELD = 'username' # use a specific password field to retrieve from login POST data PASSWORD_FORM_FIELD = 'password' # noqa # use a provided callable to transform the POSTed username into the one used in credentials USERNAME_CALLABLE = None # reset the number of failed attempts after one successful attempt RESET_ON_SUCCESS = False DISABLE_ACCESS_LOG = False HANDLER = 'axes.handlers.database.AxesDatabaseHandler' LOGGER = 'axes.watch_login' LOCKOUT_TEMPLATE = None LOCKOUT_URL = None COOLOFF_TIME = None VERBOSE = True # whitelist and blacklist NEVER_LOCKOUT_WHITELIST = False NEVER_LOCKOUT_GET = False ONLY_WHITELIST = False IP_WHITELIST = None IP_BLACKLIST = None # message to show when locked out and have cooloff enabled COOLOFF_MESSAGE = _('Account locked: too many login attempts. Please try again later') # message to show when locked out and have cooloff disabled PERMALOCK_MESSAGE = _('Account locked: too many login attempts. Contact an admin to unlock your account.') # if your deployment is using reverse proxies, set this value to 'left-most' or 'right-most' per your configuration PROXY_ORDER = 'left-most' # if your deployment is using reverse proxies, set this value to the number of proxies in front of Django PROXY_COUNT = None # if your deployment is using reverse proxies, set to your trusted proxy IP addresses prefixes if needed PROXY_TRUSTED_IPS = None # set to the names of request.META attributes that should be checked for the IP address of the client # if your deployment is using reverse proxies, ensure that the header attributes are securely set by the proxy # ensure that the client can not spoof the headers by setting them and sending them through the proxy META_PRECEDENCE_ORDER = getattr( settings, 'AXES_META_PRECEDENCE_ORDER', getattr( settings, 'IPWARE_META_PRECEDENCE_ORDER', ( 'REMOTE_ADDR', ) ) ) django-axes-5.0.7/axes/__init__.py0000644000175000017500000000015613500726664015767 0ustar jamesjames__version__ = '5.0.7' default_app_config = 'axes.apps.AppConfig' def get_version(): return __version__ django-axes-5.0.7/axes/exceptions.py0000644000175000017500000000062013500726664016405 0ustar jamesjamesfrom django.core.exceptions import PermissionDenied class AxesBackendPermissionDenied(PermissionDenied): """ Raised by authentication backend on locked out requests to stop the Django authentication flow. """ pass class AxesBackendRequestParameterRequired(ValueError): """ Raised by authentication backend on invalid or missing request parameter value. """ pass django-axes-5.0.7/axes/utils.py0000644000175000017500000000142413500726664015367 0ustar jamesjames""" Axes utility functions that are publicly available. This module is separate for historical reasons and offers a backwards compatible import path. """ from logging import getLogger from axes.models import AccessAttempt log = getLogger(__name__) def reset(ip: str = None, username: str = None) -> int: """ Reset records that match IP or username, and return the count of removed attempts. This utility method is meant to be used from the CLI or via Python API. """ attempts = AccessAttempt.objects.all() if ip: attempts = attempts.filter(ip_address=ip) if username: attempts = attempts.filter(username=username) count, _ = attempts.delete() log.info('AXES: Reset %s access attempts from database.', count) return count django-axes-5.0.7/axes/middleware.py0000644000175000017500000000225213500726664016344 0ustar jamesjamesfrom typing import Callable from axes.helpers import get_lockout_response class AxesMiddleware: """ Middleware that calculates necessary HTTP request attributes for attempt monitoring and maps lockout signals into readable HTTP 403 Forbidden responses. This middleware recognizes a logout monitoring flag in the request and and uses the ``axes.helpers.get_lockout_response`` handler for returning customizable and context aware lockout message to the end user if necessary. To customize the lockout handling behaviour further, you can subclass this middleware and change the ``__call__`` method to your own liking. Please see the following configuration flags before customizing this handler: - ``AXES_LOCKOUT_TEMPLATE``, - ``AXES_LOCKOUT_URL``, - ``AXES_COOLOFF_MESSAGE``, and - ``AXES_PERMALOCK_MESSAGE``. """ def __init__(self, get_response: Callable): self.get_response = get_response def __call__(self, request): response = self.get_response(request) if getattr(request, 'axes_locked_out', None): response = get_lockout_response(request) # type: ignore return response django-axes-5.0.7/axes/tests/0000755000175000017500000000000013500726664015016 5ustar jamesjamesdjango-axes-5.0.7/axes/tests/test_handlers.py0000644000175000017500000001554113500726664020235 0ustar jamesjamesfrom unittest.mock import MagicMock, patch from django.test import override_settings from django.utils.timezone import timedelta from axes.conf import settings from axes.handlers.proxy import AxesProxyHandler from axes.tests.base import AxesTestCase from axes.helpers import get_client_str @override_settings(AXES_HANDLER='axes.handlers.base.AxesHandler') class AxesHandlerTestCase(AxesTestCase): def test_base_handler_raises_on_undefined_is_allowed_to_authenticate(self): with self.assertRaises(NotImplementedError): AxesProxyHandler.is_allowed(self.request, {}) @override_settings(AXES_IP_BLACKLIST=['127.0.0.1']) def test_is_allowed_with_blacklisted_ip_address(self): self.assertFalse(AxesProxyHandler.is_allowed(self.request)) @override_settings( AXES_NEVER_LOCKOUT_WHITELIST=True, AXES_IP_WHITELIST=['127.0.0.1'], ) def test_is_allowed_with_whitelisted_ip_address(self): self.assertTrue(AxesProxyHandler.is_allowed(self.request)) @override_settings(AXES_NEVER_LOCKOUT_GET=True) def test_is_allowed_with_whitelisted_method(self): self.request.method = 'GET' self.assertTrue(AxesProxyHandler.is_allowed(self.request)) @override_settings(AXES_LOCK_OUT_AT_FAILURE=False) def test_is_allowed_no_lock_out(self): self.assertTrue(AxesProxyHandler.is_allowed(self.request)) class AxesProxyHandlerTestCase(AxesTestCase): def setUp(self): self.sender = MagicMock() self.credentials = MagicMock() self.request = MagicMock() self.user = MagicMock() self.instance = MagicMock() @patch('axes.handlers.proxy.AxesProxyHandler.implementation', None) def test_setting_changed_signal_triggers_handler_reimport(self): self.assertIsNone(AxesProxyHandler.implementation) with self.settings(AXES_HANDLER='axes.handlers.database.AxesDatabaseHandler'): self.assertIsNotNone(AxesProxyHandler.implementation) @patch('axes.handlers.proxy.AxesProxyHandler.implementation') def test_user_login_failed(self, handler): self.assertFalse(handler.user_login_failed.called) AxesProxyHandler.user_login_failed(self.sender, self.credentials, self.request) self.assertTrue(handler.user_login_failed.called) @patch('axes.handlers.proxy.AxesProxyHandler.implementation') def test_user_logged_in(self, handler): self.assertFalse(handler.user_logged_in.called) AxesProxyHandler.user_logged_in(self.sender, self.request, self.user) self.assertTrue(handler.user_logged_in.called) @patch('axes.handlers.proxy.AxesProxyHandler.implementation') def test_user_logged_out(self, handler): self.assertFalse(handler.user_logged_out.called) AxesProxyHandler.user_logged_out(self.sender, self.request, self.user) self.assertTrue(handler.user_logged_out.called) @patch('axes.handlers.proxy.AxesProxyHandler.implementation') def test_post_save_access_attempt(self, handler): self.assertFalse(handler.post_save_access_attempt.called) AxesProxyHandler.post_save_access_attempt(self.instance) self.assertTrue(handler.post_save_access_attempt.called) @patch('axes.handlers.proxy.AxesProxyHandler.implementation') def test_post_delete_access_attempt(self, handler): self.assertFalse(handler.post_delete_access_attempt.called) AxesProxyHandler.post_delete_access_attempt(self.instance) self.assertTrue(handler.post_delete_access_attempt.called) class AxesHandlerBaseTestCase(AxesTestCase): def check_whitelist(self, log): with override_settings( AXES_NEVER_LOCKOUT_WHITELIST=True, AXES_IP_WHITELIST=[self.ip_address], ): AxesProxyHandler.user_login_failed(sender=None, request=self.request, credentials=self.credentials) client_str = get_client_str(self.username, self.ip_address, self.user_agent, self.path_info) log.info.assert_called_with('AXES: Login failed from whitelisted client %s.', client_str) def check_empty_request(self, log, handler): AxesProxyHandler.user_login_failed(sender=None, credentials={}, request=None) log.error.assert_called_with(f'AXES: {handler}.user_login_failed does not function without a request.') @override_settings( AXES_HANDLER='axes.handlers.database.AxesDatabaseHandler', AXES_COOLOFF_TIME=timedelta(seconds=1), AXES_RESET_ON_SUCCESS=True, ) class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase): @override_settings(AXES_RESET_ON_SUCCESS=True) def test_handler(self): self.check_handler() @override_settings(AXES_RESET_ON_SUCCESS=False) def test_handler_without_reset(self): self.check_handler() @override_settings(AXES_FAILURE_LIMIT=lambda *args: 3) def test_handler_callable_failure_limit(self): self.check_handler() @override_settings(AXES_FAILURE_LIMIT='axes.tests.base.custom_failure_limit') def test_handler_str_failure_limit(self): self.check_handler() @override_settings(AXES_FAILURE_LIMIT=None) def test_handler_invalid_failure_limit(self): with self.assertRaises(TypeError): self.check_handler() @override_settings(AXES_LOCK_OUT_AT_FAILURE=False) def test_handler_without_lockout(self): self.check_handler() @patch('axes.handlers.database.log') def test_empty_request(self, log): self.check_empty_request(log, 'AxesDatabaseHandler') @patch('axes.handlers.database.log') def test_whitelist(self, log): self.check_whitelist(log) @patch('axes.handlers.database.is_user_attempt_whitelisted', return_value=True) def test_user_whitelisted(self, is_whitelisted): self.assertFalse(AxesProxyHandler().is_locked(self.request, self.credentials)) self.assertEqual(1, is_whitelisted.call_count) @override_settings( AXES_HANDLER='axes.handlers.cache.AxesCacheHandler', AXES_COOLOFF_TIME=timedelta(seconds=1), ) class AxesCacheHandlerTestCase(AxesHandlerBaseTestCase): @override_settings(AXES_RESET_ON_SUCCESS=True) def test_handler(self): self.check_handler() @override_settings(AXES_RESET_ON_SUCCESS=False) def test_handler_without_reset(self): self.check_handler() @override_settings(AXES_LOCK_OUT_AT_FAILURE=False) def test_handler_without_lockout(self): self.check_handler() @patch('axes.handlers.cache.log') def test_empty_request(self, log): self.check_empty_request(log, 'AxesCacheHandler') @patch('axes.handlers.cache.log') def test_whitelist(self, log): self.check_whitelist(log) @override_settings( AXES_HANDLER='axes.handlers.dummy.AxesDummyHandler', ) class AxesDummyHandlerTestCase(AxesHandlerBaseTestCase): def test_handler(self): for _ in range(settings.AXES_FAILURE_LIMIT): self.login() self.check_login() django-axes-5.0.7/axes/tests/test_logging.py0000644000175000017500000001026113500726664020055 0ustar jamesjamesfrom unittest.mock import patch from django.contrib.auth import authenticate from django.http import HttpRequest from django.test import override_settings from django.urls import reverse from axes.apps import AppConfig from axes.models import AccessAttempt, AccessLog from axes.tests.base import AxesTestCase @patch('axes.apps.AppConfig.logging_initialized', False) @patch('axes.apps.log') class AppsTestCase(AxesTestCase): def test_axes_config_log_re_entrant(self, log): """ Test that initialize call count does not increase on repeat calls. """ AppConfig.initialize() calls = log.info.call_count AppConfig.initialize() self.assertTrue( calls == log.info.call_count and calls > 0, 'AxesConfig.initialize needs to be re-entrant', ) @override_settings(AXES_VERBOSE=False) def test_axes_config_log_not_verbose(self, log): AppConfig.initialize() self.assertFalse(log.info.called) @override_settings(AXES_ONLY_USER_FAILURES=True) def test_axes_config_log_user_only(self, log): AppConfig.initialize() log.info.assert_called_with('AXES: blocking by username only.') @override_settings(AXES_ONLY_USER_FAILURES=False) def test_axes_config_log_ip_only(self, log): AppConfig.initialize() log.info.assert_called_with('AXES: blocking by IP only.') @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) def test_axes_config_log_user_ip(self, log): AppConfig.initialize() log.info.assert_called_with('AXES: blocking by combination of username and IP.') class AccessLogTestCase(AxesTestCase): def test_access_log_on_logout(self): """ Test a valid logout and make sure the logout_time is updated. """ self.login(is_valid_username=True, is_valid_password=True) self.assertIsNone(AccessLog.objects.latest('id').logout_time) response = self.client.get(reverse('admin:logout')) self.assertContains(response, 'Logged out') self.assertIsNotNone(AccessLog.objects.latest('id').logout_time) def test_log_data_truncated(self): """ Test that get_query_str properly truncates data to the max_length (default 1024). """ # An impossibly large post dict extra_data = {'a' * x: x for x in range(1024)} self.login(**extra_data) self.assertEqual( len(AccessAttempt.objects.latest('id').post_data), 1024 ) @override_settings(AXES_DISABLE_ACCESS_LOG=True) def test_valid_logout_without_success_log(self): AccessLog.objects.all().delete() response = self.login(is_valid_username=True, is_valid_password=True) response = self.client.get(reverse('admin:logout')) self.assertEqual(AccessLog.objects.all().count(), 0) self.assertContains(response, 'Logged out', html=True) @override_settings(AXES_DISABLE_ACCESS_LOG=True) def test_valid_login_without_success_log(self): """ Test that a valid login does not generate an AccessLog when DISABLE_SUCCESS_ACCESS_LOG is True. """ AccessLog.objects.all().delete() response = self.login(is_valid_username=True, is_valid_password=True) self.assertEqual(response.status_code, 302) self.assertEqual(AccessLog.objects.all().count(), 0) @override_settings(AXES_DISABLE_ACCESS_LOG=True) def test_valid_logout_without_log(self): AccessLog.objects.all().delete() response = self.login(is_valid_username=True, is_valid_password=True) response = self.client.get(reverse('admin:logout')) self.assertEqual(AccessLog.objects.count(), 0) self.assertContains(response, 'Logged out', html=True) @override_settings(AXES_DISABLE_ACCESS_LOG=True) def test_non_valid_login_without_log(self): """ Test that a non-valid login does generate an AccessLog when DISABLE_ACCESS_LOG is True. """ AccessLog.objects.all().delete() response = self.login(is_valid_username=True, is_valid_password=False) self.assertEqual(response.status_code, 200) self.assertEqual(AccessLog.objects.all().count(), 0) django-axes-5.0.7/axes/tests/test_backends.py0000644000175000017500000000134313500726664020202 0ustar jamesjamesfrom unittest.mock import patch, MagicMock from axes.backends import AxesBackend from axes.exceptions import AxesBackendRequestParameterRequired, AxesBackendPermissionDenied from axes.tests.base import AxesTestCase class BackendTestCase(AxesTestCase): def test_authenticate_raises_on_missing_request(self): request = None with self.assertRaises(AxesBackendRequestParameterRequired): AxesBackend().authenticate(request) @patch('axes.handlers.proxy.AxesProxyHandler.is_allowed', return_value=False) def test_authenticate_raises_on_locked_request(self, _): request = MagicMock() with self.assertRaises(AxesBackendPermissionDenied): AxesBackend().authenticate(request) django-axes-5.0.7/axes/tests/urls.py0000644000175000017500000000017413500726664016357 0ustar jamesjamesfrom django.conf.urls import url from django.contrib import admin urlpatterns = [ url(r'^admin/', admin.site.urls), ] django-axes-5.0.7/axes/tests/test_attempts.py0000644000175000017500000000475013500726664020276 0ustar jamesjamesfrom unittest.mock import patch from django.contrib.auth import get_user_model from django.http import HttpRequest from django.test import override_settings from django.utils.timezone import now from axes.attempts import is_user_attempt_whitelisted, get_cool_off_threshold from axes.models import AccessAttempt from axes.tests.base import AxesTestCase from axes.utils import reset class GetCoolOffThresholdTestCase(AxesTestCase): @override_settings(AXES_COOLOFF_TIME=42) def test_get_cool_off_threshold(self): timestamp = now() with patch('axes.attempts.now', return_value=timestamp): attempt_time = timestamp threshold_now = get_cool_off_threshold(attempt_time) attempt_time = None threshold_none = get_cool_off_threshold(attempt_time) self.assertEqual(threshold_now, threshold_none) @override_settings(AXES_COOLOFF_TIME=None) def test_get_cool_off_threshold_error(self): with self.assertRaises(TypeError): get_cool_off_threshold() class ResetTestCase(AxesTestCase): def test_reset(self): self.create_attempt() reset() self.assertFalse(AccessAttempt.objects.count()) def test_reset_ip(self): self.create_attempt(ip_address=self.ip_address) reset(ip=self.ip_address) self.assertFalse(AccessAttempt.objects.count()) def test_reset_username(self): self.create_attempt(username=self.username) reset(username=self.username) self.assertFalse(AccessAttempt.objects.count()) class UserWhitelistTestCase(AxesTestCase): def setUp(self): self.user_model = get_user_model() self.user = self.user_model.objects.create(username='jane.doe') self.request = HttpRequest() def test_is_client_username_whitelisted(self): with patch.object(self.user_model, 'nolockout', True, create=True): self.assertTrue(is_user_attempt_whitelisted( self.request, {self.user_model.USERNAME_FIELD: self.user.username}, )) def test_is_client_username_whitelisted_not(self): self.assertFalse(is_user_attempt_whitelisted( self.request, {self.user_model.USERNAME_FIELD: self.user.username}, )) def test_is_client_username_whitelisted_does_not_exist(self): self.assertFalse(is_user_attempt_whitelisted( self.request, {self.user_model.USERNAME_FIELD: 'not.' + self.user.username}, )) django-axes-5.0.7/axes/tests/__init__.py0000644000175000017500000000000013500726664017115 0ustar jamesjamesdjango-axes-5.0.7/axes/tests/base.py0000644000175000017500000001273013500726664016305 0ustar jamesjamesfrom random import choice from string import ascii_letters, digits from time import sleep from django.contrib.auth import get_user_model from django.http import HttpRequest from django.test import TestCase from django.urls import reverse from django.utils.timezone import now from axes.utils import reset from axes.conf import settings from axes.helpers import ( get_cache, get_client_http_accept, get_client_ip_address, get_client_path_info, get_client_user_agent, get_cool_off, get_credentials, get_failure_limit, ) from axes.models import AccessAttempt def custom_failure_limit(request, credentials): return 3 class AxesTestCase(TestCase): """ Test case using custom settings for testing. """ VALID_USERNAME = 'axes-valid-username' VALID_PASSWORD = 'axes-valid-password' VALID_EMAIL = 'axes-valid-email@example.com' VALID_USER_AGENT = 'axes-user-agent' VALID_IP_ADDRESS = '127.0.0.1' INVALID_USERNAME = 'axes-invalid-username' INVALID_PASSWORD = 'axes-invalid-password' INVALID_EMAIL = 'axes-invalid-email@example.com' LOCKED_MESSAGE = 'Account locked: too many login attempts.' LOGOUT_MESSAGE = 'Logged out' LOGIN_FORM_KEY = '' STATUS_SUCCESS = 200 ALLOWED = 302 BLOCKED = 403 def setUp(self): """ Create a valid user for login. """ self.username = self.VALID_USERNAME self.password = self.VALID_PASSWORD self.email = self.VALID_EMAIL self.ip_address = self.VALID_IP_ADDRESS self.user_agent = self.VALID_USER_AGENT self.path_info = reverse('admin:login') self.user = get_user_model().objects.create_superuser( username=self.username, password=self.password, email=self.email, ) self.request = HttpRequest() self.request.method = 'POST' self.request.META['REMOTE_ADDR'] = self.ip_address self.request.META['HTTP_USER_AGENT'] = self.user_agent self.request.META['PATH_INFO'] = self.path_info self.request.axes_attempt_time = now() self.request.axes_ip_address = get_client_ip_address(self.request) self.request.axes_user_agent = get_client_user_agent(self.request) self.request.axes_path_info = get_client_path_info(self.request) self.request.axes_http_accept = get_client_http_accept(self.request) self.credentials = get_credentials(self.username) def tearDown(self): get_cache().clear() def get_kwargs_with_defaults(self, **kwargs): defaults = { 'user_agent': self.user_agent, 'ip_address': self.ip_address, 'username': self.username, 'failures_since_start': 1, } defaults.update(kwargs) return defaults def create_attempt(self, **kwargs): return AccessAttempt.objects.create(**self.get_kwargs_with_defaults(**kwargs)) def reset(self, ip=None, username=None): return reset(ip, username) def login(self, is_valid_username=False, is_valid_password=False, **kwargs): """ Login a user. A valid credential is used when is_valid_username is True, otherwise it will use a random string to make a failed login. """ if is_valid_username: username = self.VALID_USERNAME else: username = ''.join( choice(ascii_letters + digits) for _ in range(10) ) if is_valid_password: password = self.VALID_PASSWORD else: password = self.INVALID_PASSWORD post_data = { 'username': username, 'password': password, **kwargs } return self.client.post( reverse('admin:login'), post_data, REMOTE_ADDR=self.ip_address, HTTP_USER_AGENT=self.user_agent, ) def logout(self): return self.client.post( reverse('admin:logout'), REMOTE_ADDR=self.ip_address, HTTP_USER_AGENT=self.user_agent, ) def check_login(self): response = self.login(is_valid_username=True, is_valid_password=True) self.assertNotContains(response, self.LOGIN_FORM_KEY, status_code=self.ALLOWED, html=True) def almost_lockout(self): for _ in range(1, get_failure_limit(None, None)): response = self.login() self.assertContains(response, self.LOGIN_FORM_KEY, html=True) def lockout(self): self.almost_lockout() return self.login() def check_lockout(self): response = self.lockout() if settings.AXES_LOCK_OUT_AT_FAILURE == True: self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED) else: self.assertNotContains(response, self.LOCKED_MESSAGE, status_code=self.STATUS_SUCCESS) def cool_off(self): sleep(get_cool_off().total_seconds()) def check_logout(self): response = self.logout() self.assertContains(response, self.LOGOUT_MESSAGE, status_code=self.STATUS_SUCCESS) def check_handler(self): """ Check a handler and its basic functionality with lockouts, cool offs, login, and logout. This is a check that is intended to successfully run for each and every new handler. """ self.check_lockout() self.cool_off() self.check_login() self.check_logout() django-axes-5.0.7/axes/tests/settings.py0000644000175000017500000000420013500726664017224 0ustar jamesjamesimport os.path import tempfile DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:', } } CACHES = { 'default': { # This cache backend is OK to use in development and testing # but has the potential to break production setups with more than on process # due to each process having their own local memory based cache 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', } } SITE_ID = 1 MIDDLEWARE = [ 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'axes.middleware.AxesMiddleware', ] AUTHENTICATION_BACKENDS = [ 'axes.backends.AxesBackend', 'django.contrib.auth.backends.ModelBackend', ] PASSWORD_HASHERS = [ 'django.contrib.auth.hashers.MD5PasswordHasher', ] ROOT_URLCONF = 'axes.tests.urls' INSTALLED_APPS = [ 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.messages', 'django.contrib.admin', 'axes', ] TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', ], }, }, ] LOGGING = { 'version': 1, 'disable_existing_loggers': False, 'handlers': { 'console': { 'class': 'logging.StreamHandler', }, }, 'loggers': { 'axes': { 'handlers': ['console'], 'level': 'INFO', 'propagate': False, }, }, } SECRET_KEY = 'too-secret-for-test' USE_I18N = False USE_L10N = False USE_TZ = False LOGIN_REDIRECT_URL = '/admin/' AXES_FAILURE_LIMIT = 10 django-axes-5.0.7/axes/tests/test_decorators.py0000644000175000017500000000363013500726664020576 0ustar jamesjamesfrom unittest.mock import MagicMock, patch from django.http import HttpResponse from axes.decorators import axes_dispatch, axes_form_invalid from axes.tests.base import AxesTestCase class DecoratorTestCase(AxesTestCase): SUCCESS_RESPONSE = HttpResponse(status=200, content='Dispatched') LOCKOUT_RESPONSE = HttpResponse(status=403, content='Locked out') def setUp(self): self.request = MagicMock() self.cls = MagicMock(return_value=self.request) self.func = MagicMock(return_value=self.SUCCESS_RESPONSE) @patch('axes.handlers.proxy.AxesProxyHandler.is_allowed', return_value=False) @patch('axes.decorators.get_lockout_response', return_value=LOCKOUT_RESPONSE) def test_axes_dispatch_locks_out(self, _, __): response = axes_dispatch(self.func)(self.request) self.assertEqual(response.content, self.LOCKOUT_RESPONSE.content) @patch('axes.handlers.proxy.AxesProxyHandler.is_allowed', return_value=True) @patch('axes.decorators.get_lockout_response', return_value=LOCKOUT_RESPONSE) def test_axes_dispatch_dispatches(self, _, __): response = axes_dispatch(self.func)(self.request) self.assertEqual(response.content, self.SUCCESS_RESPONSE.content) @patch('axes.handlers.proxy.AxesProxyHandler.is_allowed', return_value=False) @patch('axes.decorators.get_lockout_response', return_value=LOCKOUT_RESPONSE) def test_axes_form_invalid_locks_out(self, _, __): response = axes_form_invalid(self.func)(self.cls) self.assertEqual(response.content, self.LOCKOUT_RESPONSE.content) @patch('axes.handlers.proxy.AxesProxyHandler.is_allowed', return_value=True) @patch('axes.decorators.get_lockout_response', return_value=LOCKOUT_RESPONSE) def test_axes_form_invalid_dispatches(self, _, __): response = axes_form_invalid(self.func)(self.cls) self.assertEqual(response.content, self.SUCCESS_RESPONSE.content) django-axes-5.0.7/axes/tests/test_login.py0000644000175000017500000005137713500726664017554 0ustar jamesjames""" Integration tests for the login handling. TODO: Clean up the tests in this module. """ from importlib import import_module from django.http import HttpRequest from django.test import override_settings, TestCase from django.urls import reverse from django.contrib.auth import get_user_model, login, logout from axes.conf import settings from axes.models import AccessLog, AccessAttempt from axes.tests.base import AxesTestCase class DjangoLoginTestCase(TestCase): def setUp(self): engine = import_module(settings.SESSION_ENGINE) self.request = HttpRequest() self.request.session = engine.SessionStore() self.username = 'john.doe' self.password = 'hunter2' self.user = get_user_model().objects.create(username=self.username) self.user.set_password(self.password) self.user.save() self.user.backend = 'django.contrib.auth.backends.ModelBackend' class DjangoContribAuthLoginTestCase(DjangoLoginTestCase): def test_login(self): login(self.request, self.user) def test_logout(self): login(self.request, self.user) logout(self.request) @override_settings(AXES_ENABLED=False) class DjangoTestClientLoginTestCase(DjangoLoginTestCase): def test_client_login(self): self.client.login(username=self.username, password=self.password) def test_client_logout(self): self.client.login(username=self.username, password=self.password) self.client.logout() def test_client_force_login(self): self.client.force_login(self.user) class LoginTestCase(AxesTestCase): """ Test for lockouts under different configurations and circumstances to prevent false positives and false negatives. Always block attempted logins for the same user from the same IP. Always allow attempted logins for a different user from a different IP. """ IP_1 = '10.1.1.1' IP_2 = '10.2.2.2' USER_1 = 'valid-user-1' USER_2 = 'valid-user-2' EMAIL_1 = 'valid-email-1@example.com' EMAIL_2 = 'valid-email-2@example.com' VALID_USERNAME = USER_1 VALID_EMAIL = EMAIL_1 VALID_PASSWORD = 'valid-password' VALID_IP_ADDRESS = IP_1 WRONG_PASSWORD = 'wrong-password' LOCKED_MESSAGE = 'Account locked: too many login attempts.' LOGIN_FORM_KEY = '' ALLOWED = 302 BLOCKED = 403 def _login(self, username, password, ip_addr='127.0.0.1', **kwargs): """ Login a user and get the response. IP address can be configured to test IP blocking functionality. """ post_data = { 'username': username, 'password': password, } post_data.update(kwargs) return self.client.post( reverse('admin:login'), post_data, REMOTE_ADDR=ip_addr, HTTP_USER_AGENT='test-browser' ) def _lockout_user_from_ip(self, username, ip_addr): for _ in range(settings.AXES_FAILURE_LIMIT): response = self._login( username=username, password=self.WRONG_PASSWORD, ip_addr=ip_addr, ) return response def _lockout_user1_from_ip1(self): return self._lockout_user_from_ip( username=self.USER_1, ip_addr=self.IP_1, ) def setUp(self): """ Create two valid users for authentication. """ super().setUp() self.user2 = get_user_model().objects.create_superuser( username=self.USER_2, email=self.EMAIL_2, password=self.VALID_PASSWORD, is_staff=True, is_superuser=True, ) def test_login(self): """ Test a valid login for a real username. """ response = self._login(self.username, self.password) self.assertNotContains(response, self.LOGIN_FORM_KEY, status_code=self.ALLOWED, html=True) def test_lockout_limit_once(self): """ Test the login lock trying to login one more time than failure limit. """ response = self.lockout() self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED) def test_lockout_limit_many(self): """ Test the login lock trying to login a lot of times more than failure limit. """ self.lockout() for _ in range(settings.AXES_FAILURE_LIMIT): response = self.login() self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED) @override_settings(AXES_RESET_ON_SUCCESS=False) def test_reset_on_success_false(self): self.almost_lockout() self.login(is_valid_username=True, is_valid_password=True) response = self.login() self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED) self.assertTrue(AccessAttempt.objects.count()) @override_settings(AXES_RESET_ON_SUCCESS=True) def test_reset_on_success_true(self): self.almost_lockout() self.assertTrue(AccessAttempt.objects.count()) self.login(is_valid_username=True, is_valid_password=True) self.assertFalse(AccessAttempt.objects.count()) response = self.lockout() self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED) self.assertTrue(AccessAttempt.objects.count()) @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) def test_lockout_by_combination_user_and_ip(self): """ Test login failure when AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP is True. """ # test until one try before the limit for _ in range(1, settings.AXES_FAILURE_LIMIT): response = self.login( is_valid_username=True, is_valid_password=False, ) # Check if we are in the same login page self.assertContains(response, self.LOGIN_FORM_KEY, html=True) # So, we shouldn't have gotten a lock-out yet. # But we should get one now response = self.login(is_valid_username=True, is_valid_password=False) self.assertContains(response, self.LOCKED_MESSAGE, status_code=403) @override_settings(AXES_ONLY_USER_FAILURES=True) def test_lockout_by_only_user_failures(self): """ Test login failure when AXES_ONLY_USER_FAILURES is True. """ # test until one try before the limit for _ in range(1, settings.AXES_FAILURE_LIMIT): response = self._login(self.username, self.WRONG_PASSWORD) # Check if we are in the same login page self.assertContains(response, self.LOGIN_FORM_KEY, html=True) # So, we shouldn't have gotten a lock-out yet. # But we should get one now response = self._login(self.username, self.WRONG_PASSWORD) self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED) # reset the username only and make sure we can log in now even though our IP has failed each time self.reset(username=self.username) response = self._login(self.username, self.password) # Check if we are still in the login page self.assertNotContains(response, self.LOGIN_FORM_KEY, status_code=self.ALLOWED, html=True) # now create failure_limit + 1 failed logins and then we should still # be able to login with valid_username for _ in range(settings.AXES_FAILURE_LIMIT): response = self._login(self.username, self.password) # Check if we can still log in with valid user response = self._login(self.username, self.password) self.assertNotContains(response, self.LOGIN_FORM_KEY, status_code=self.ALLOWED, html=True) # Test for true and false positives when blocking by IP *OR* user (default) # Cache disabled. Default settings. def test_lockout_by_ip_blocks_when_same_user_same_ip_without_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 1 is still blocked from IP 1. response = self._login( self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1 ) self.assertEqual(response.status_code, self.BLOCKED) def test_lockout_by_ip_allows_when_same_user_diff_ip_without_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 1 can still login from IP 2. response = self._login( self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2 ) self.assertEqual(response.status_code, self.ALLOWED) def test_lockout_by_ip_blocks_when_diff_user_same_ip_without_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 2 is also locked out from IP 1. response = self._login( self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1 ) self.assertEqual(response.status_code, self.BLOCKED) def test_lockout_by_ip_allows_when_diff_user_diff_ip_without_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 2 can still login from IP 2. response = self._login( self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2 ) self.assertEqual(response.status_code, self.ALLOWED) # Test for true and false positives when blocking by user only. # Cache disabled. When AXES_ONLY_USER_FAILURES = True @override_settings(AXES_ONLY_USER_FAILURES=True) def test_lockout_by_user_blocks_when_same_user_same_ip_without_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 1 is still blocked from IP 1. response = self._login( self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1 ) self.assertEqual(response.status_code, self.BLOCKED) @override_settings(AXES_ONLY_USER_FAILURES=True) def test_lockout_by_user_blocks_when_same_user_diff_ip_without_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 1 is also locked out from IP 2. response = self._login( self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2 ) self.assertEqual(response.status_code, self.BLOCKED) @override_settings(AXES_ONLY_USER_FAILURES=True) def test_lockout_by_user_allows_when_diff_user_same_ip_without_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 2 can still login from IP 1. response = self._login( self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1 ) self.assertEqual(response.status_code, self.ALLOWED) @override_settings(AXES_ONLY_USER_FAILURES=True) def test_lockout_by_user_allows_when_diff_user_diff_ip_without_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 2 can still login from IP 2. response = self._login( self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2 ) self.assertEqual(response.status_code, self.ALLOWED) @override_settings(AXES_ONLY_USER_FAILURES=True) def test_lockout_by_user_with_empty_username_allows_other_users_without_cache(self): # User with empty username is locked out from IP 1. self._lockout_user_from_ip(username='', ip_addr=self.IP_1) # Still possible to access the login page response = self.client.get(reverse('admin:login'), REMOTE_ADDR=self.IP_1) self.assertContains(response, self.LOGIN_FORM_KEY, status_code=200, html=True) # Test for true and false positives when blocking by user and IP together. # Cache disabled. When LOCK_OUT_BY_COMBINATION_USER_AND_IP = True @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) def test_lockout_by_user_and_ip_blocks_when_same_user_same_ip_without_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 1 is still blocked from IP 1. response = self._login( self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1 ) self.assertEqual(response.status_code, self.BLOCKED) @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) def test_lockout_by_user_and_ip_allows_when_same_user_diff_ip_without_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 1 can still login from IP 2. response = self._login( self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2 ) self.assertEqual(response.status_code, self.ALLOWED) @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_without_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 2 can still login from IP 1. response = self._login( self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1 ) self.assertEqual(response.status_code, self.ALLOWED) @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) def test_lockout_by_user_and_ip_allows_when_diff_user_diff_ip_without_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 2 can still login from IP 2. response = self._login( self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2 ) self.assertEqual(response.status_code, self.ALLOWED) @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) def test_lockout_by_user_and_ip_with_empty_username_allows_other_users_without_cache(self): # User with empty username is locked out from IP 1. self._lockout_user_from_ip(username='', ip_addr=self.IP_1) # Still possible to access the login page response = self.client.get(reverse('admin:login'), REMOTE_ADDR=self.IP_1) self.assertContains(response, self.LOGIN_FORM_KEY, status_code=200, html=True) # Test for true and false positives when blocking by IP *OR* user (default) # With cache enabled. Default criteria. def test_lockout_by_ip_blocks_when_same_user_same_ip_using_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 1 is still blocked from IP 1. response = self._login( self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1 ) self.assertEqual(response.status_code, self.BLOCKED) def test_lockout_by_ip_allows_when_same_user_diff_ip_using_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 1 can still login from IP 2. response = self._login( self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2 ) self.assertEqual(response.status_code, self.ALLOWED) def test_lockout_by_ip_blocks_when_diff_user_same_ip_using_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 2 is also locked out from IP 1. response = self._login( self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1 ) self.assertEqual(response.status_code, self.BLOCKED) def test_lockout_by_ip_allows_when_diff_user_diff_ip_using_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 2 can still login from IP 2. response = self._login( self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2 ) self.assertEqual(response.status_code, self.ALLOWED) @override_settings(AXES_ONLY_USER_FAILURES=True) def test_lockout_by_user_with_empty_username_allows_other_users_using_cache(self): # User with empty username is locked out from IP 1. self._lockout_user_from_ip(username='', ip_addr=self.IP_1) # Still possible to access the login page response = self.client.get(reverse('admin:login'), REMOTE_ADDR=self.IP_1) self.assertContains(response, self.LOGIN_FORM_KEY, status_code=200, html=True) # Test for true and false positives when blocking by user only. # With cache enabled. When AXES_ONLY_USER_FAILURES = True @override_settings(AXES_ONLY_USER_FAILURES=True) def test_lockout_by_user_blocks_when_same_user_same_ip_using_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 1 is still blocked from IP 1. response = self._login( self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1 ) self.assertEqual(response.status_code, self.BLOCKED) @override_settings(AXES_ONLY_USER_FAILURES=True) def test_lockout_by_user_blocks_when_same_user_diff_ip_using_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 1 is also locked out from IP 2. response = self._login( self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2 ) self.assertEqual(response.status_code, self.BLOCKED) @override_settings(AXES_ONLY_USER_FAILURES=True) def test_lockout_by_user_allows_when_diff_user_same_ip_using_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 2 can still login from IP 1. response = self._login( self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1 ) self.assertEqual(response.status_code, self.ALLOWED) @override_settings(AXES_ONLY_USER_FAILURES=True) def test_lockout_by_user_allows_when_diff_user_diff_ip_using_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 2 can still login from IP 2. response = self._login( self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2 ) self.assertEqual(response.status_code, self.ALLOWED) # Test for true and false positives when blocking by user and IP together. # With cache enabled. When LOCK_OUT_BY_COMBINATION_USER_AND_IP = True @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) def test_lockout_by_user_and_ip_blocks_when_same_user_same_ip_using_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 1 is still blocked from IP 1. response = self._login( self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1 ) self.assertEqual(response.status_code, self.BLOCKED) @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) def test_lockout_by_user_and_ip_allows_when_same_user_diff_ip_using_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 1 can still login from IP 2. response = self._login( self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2 ) self.assertEqual(response.status_code, self.ALLOWED) @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_using_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 2 can still login from IP 1. response = self._login( self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1 ) self.assertEqual(response.status_code, self.ALLOWED) @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) def test_lockout_by_user_and_ip_allows_when_diff_user_diff_ip_using_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 2 can still login from IP 2. response = self._login( self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2 ) self.assertEqual(response.status_code, self.ALLOWED) @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) def test_lockout_by_user_and_ip_with_empty_username_allows_other_users_using_cache(self): # User with empty username is locked out from IP 1. self._lockout_user_from_ip(username='', ip_addr=self.IP_1) # Still possible to access the login page response = self.client.get(reverse('admin:login'), REMOTE_ADDR=self.IP_1) self.assertContains(response, self.LOGIN_FORM_KEY, status_code=200, html=True) django-axes-5.0.7/axes/tests/test_utils.py0000644000175000017500000005145413500726664017600 0ustar jamesjamesfrom datetime import timedelta from hashlib import md5 from unittest.mock import patch from django.http import JsonResponse, HttpResponseRedirect, HttpResponse, HttpRequest from django.test import override_settings, RequestFactory from axes import get_version from axes.apps import AppConfig from axes.models import AccessAttempt from axes.tests.base import AxesTestCase from axes.helpers import ( get_cache_timeout, get_client_str, get_client_username, get_client_cache_key, get_client_parameters, get_cool_off_iso8601, get_lockout_response, is_client_ip_address_blacklisted, is_client_ip_address_whitelisted, is_ip_address_in_blacklist, is_ip_address_in_whitelist, is_client_method_whitelisted, toggleable, ) class VersionTestCase(AxesTestCase): @patch('axes.__version__', 'test') def test_get_version(self): self.assertEqual(get_version(), 'test') @override_settings(AXES_ENABLED=False) class AxesDisabledTestCase(AxesTestCase): def test_initialize(self): AppConfig.logging_initialized = False AppConfig.initialize() self.assertFalse(AppConfig.logging_initialized) def test_toggleable(self): def is_true(): return True self.assertTrue(is_true()) self.assertIsNone(toggleable(is_true)()) class CacheTestCase(AxesTestCase): @override_settings(AXES_COOLOFF_TIME=3) # hours def test_get_cache_timeout_integer(self): timeout_seconds = float(60 * 60 * 3) self.assertEqual(get_cache_timeout(), timeout_seconds) @override_settings(AXES_COOLOFF_TIME=timedelta(seconds=420)) def test_get_cache_timeout_timedelta(self): self.assertEqual(get_cache_timeout(), 420) @override_settings(AXES_COOLOFF_TIME=None) def test_get_cache_timeout_none(self): self.assertEqual(get_cache_timeout(), None) class TimestampTestCase(AxesTestCase): def test_iso8601(self): """ Test get_cool_off_iso8601 correctly translates datetime.timdelta to ISO 8601 formatted duration. """ expected = { timedelta(days=1, hours=25, minutes=42, seconds=8): 'P2DT1H42M8S', timedelta(days=7, seconds=342): 'P7DT5M42S', timedelta(days=0, hours=2, minutes=42): 'PT2H42M', timedelta(hours=20, seconds=42): 'PT20H42S', timedelta(seconds=300): 'PT5M', timedelta(seconds=9005): 'PT2H30M5S', timedelta(minutes=9005): 'P6DT6H5M', timedelta(days=15): 'P15D' } for delta, iso_duration in expected.items(): with self.subTest(iso_duration): self.assertEqual(get_cool_off_iso8601(delta), iso_duration) class ClientStringTestCase(AxesTestCase): @staticmethod def get_expected_client_str(*args, **kwargs): client_str_template = '{{username: "{0}", ip_address: "{1}", user_agent: "{2}", path_info: "{3}"}}' return client_str_template.format(*args, **kwargs) @override_settings(AXES_VERBOSE=True) def test_verbose_ip_only_client_details(self): username = 'test@example.com' ip_address = '127.0.0.1' user_agent = 'Googlebot/2.1 (+http://www.googlebot.com/bot.html)' path_info = '/admin/' expected = self.get_expected_client_str(username, ip_address, user_agent, path_info) actual = get_client_str(username, ip_address, user_agent, path_info) self.assertEqual(expected, actual) @override_settings(AXES_VERBOSE=True) def test_verbose_ip_only_client_details_tuple(self): username = 'test@example.com' ip_address = '127.0.0.1' user_agent = 'Googlebot/2.1 (+http://www.googlebot.com/bot.html)' path_info = ('admin', 'login') expected = self.get_expected_client_str(username, ip_address, user_agent, path_info[0]) actual = get_client_str(username, ip_address, user_agent, path_info) self.assertEqual(expected, actual) @override_settings(AXES_VERBOSE=False) def test_non_verbose_ip_only_client_details(self): username = 'test@example.com' ip_address = '127.0.0.1' user_agent = 'Googlebot/2.1 (+http://www.googlebot.com/bot.html)' path_info = '/admin/' expected = '{ip_address: "127.0.0.1", path_info: "/admin/"}' actual = get_client_str(username, ip_address, user_agent, path_info) self.assertEqual(expected, actual) @override_settings(AXES_ONLY_USER_FAILURES=True) @override_settings(AXES_VERBOSE=True) def test_verbose_user_only_client_details(self): username = 'test@example.com' ip_address = '127.0.0.1' user_agent = 'Googlebot/2.1 (+http://www.googlebot.com/bot.html)' path_info = '/admin/' expected = self.get_expected_client_str(username, ip_address, user_agent, path_info) actual = get_client_str(username, ip_address, user_agent, path_info) self.assertEqual(expected, actual) @override_settings(AXES_ONLY_USER_FAILURES=True) @override_settings(AXES_VERBOSE=False) def test_non_verbose_user_only_client_details(self): username = 'test@example.com' ip_address = '127.0.0.1' user_agent = 'Googlebot/2.1 (+http://www.googlebot.com/bot.html)' path_info = '/admin/' expected = '{username: "test@example.com", path_info: "/admin/"}' actual = get_client_str(username, ip_address, user_agent, path_info) self.assertEqual(expected, actual) @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) @override_settings(AXES_VERBOSE=True) def test_verbose_user_ip_combo_client_details(self): username = 'test@example.com' ip_address = '127.0.0.1' user_agent = 'Googlebot/2.1 (+http://www.googlebot.com/bot.html)' path_info = '/admin/' expected = self.get_expected_client_str(username, ip_address, user_agent, path_info) actual = get_client_str(username, ip_address, user_agent, path_info) self.assertEqual(expected, actual) @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) @override_settings(AXES_VERBOSE=False) def test_non_verbose_user_ip_combo_client_details(self): username = 'test@example.com' ip_address = '127.0.0.1' user_agent = 'Googlebot/2.1 (+http://www.googlebot.com/bot.html)' path_info = '/admin/' expected = '{username: "test@example.com", ip_address: "127.0.0.1", path_info: "/admin/"}' actual = get_client_str(username, ip_address, user_agent, path_info) self.assertEqual(expected, actual) @override_settings(AXES_USE_USER_AGENT=True) @override_settings(AXES_VERBOSE=True) def test_verbose_user_agent_client_details(self): username = 'test@example.com' ip_address = '127.0.0.1' user_agent = 'Googlebot/2.1 (+http://www.googlebot.com/bot.html)' path_info = '/admin/' expected = self.get_expected_client_str(username, ip_address, user_agent, path_info) actual = get_client_str(username, ip_address, user_agent, path_info) self.assertEqual(expected, actual) @override_settings(AXES_USE_USER_AGENT=True) @override_settings(AXES_VERBOSE=False) def test_non_verbose_user_agent_client_details(self): username = 'test@example.com' ip_address = '127.0.0.1' user_agent = 'Googlebot/2.1 (+http://www.googlebot.com/bot.html)' path_info = '/admin/' expected = '{ip_address: "127.0.0.1", user_agent: "Googlebot/2.1 (+http://www.googlebot.com/bot.html)", path_info: "/admin/"}' actual = get_client_str(username, ip_address, user_agent, path_info) self.assertEqual(expected, actual) class ClientParametersTestCase(AxesTestCase): @override_settings( AXES_ONLY_USER_FAILURES=True, ) def test_get_filter_kwargs_user(self): self.assertEqual( dict(get_client_parameters(self.username, self.ip_address, self.user_agent)), {'username': self.username}, ) @override_settings( AXES_ONLY_USER_FAILURES=False, AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=False, AXES_USE_USER_AGENT=False, ) def test_get_filter_kwargs_ip(self): self.assertEqual( dict(get_client_parameters(self.username, self.ip_address, self.user_agent)), {'ip_address': self.ip_address}, ) @override_settings( AXES_ONLY_USER_FAILURES=False, AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True, AXES_USE_USER_AGENT=False, ) def test_get_filter_kwargs_user_and_ip(self): self.assertEqual( dict(get_client_parameters(self.username, self.ip_address, self.user_agent)), {'username': self.username, 'ip_address': self.ip_address}, ) @override_settings( AXES_ONLY_USER_FAILURES=False, AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=False, AXES_USE_USER_AGENT=True, ) def test_get_filter_kwargs_ip_and_agent(self): self.assertEqual( dict(get_client_parameters(self.username, self.ip_address, self.user_agent)), {'ip_address': self.ip_address, 'user_agent': self.user_agent}, ) @override_settings( AXES_ONLY_USER_FAILURES=False, AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True, AXES_USE_USER_AGENT=True, ) def test_get_filter_kwargs_user_ip_agent(self): self.assertEqual( dict(get_client_parameters(self.username, self.ip_address, self.user_agent)), {'username': self.username, 'ip_address': self.ip_address, 'user_agent': self.user_agent}, ) class ClientCacheKeyTestCase(AxesTestCase): def test_get_cache_key(self): """ Test the cache key format. """ cache_hash_digest = md5(self.ip_address.encode()).hexdigest() cache_hash_key = f'axes-{cache_hash_digest}' # Getting cache key from request request_factory = RequestFactory() request = request_factory.post( '/admin/login/', data={ 'username': self.username, 'password': 'test', }, ) self.assertEqual(cache_hash_key, get_client_cache_key(request)) # Getting cache key from AccessAttempt Object attempt = AccessAttempt( user_agent='', ip_address=self.ip_address, username=self.username, get_data='', post_data='', http_accept=request.META.get('HTTP_ACCEPT', ''), path_info=request.META.get('PATH_INFO', ''), failures_since_start=0, ) self.assertEqual(cache_hash_key, get_client_cache_key(attempt)) def test_get_cache_key_empty_ip_address(self): """ Simulate an empty IP address in the request. """ empty_ip_address = '' cache_hash_digest = md5(empty_ip_address.encode()).hexdigest() cache_hash_key = f'axes-{cache_hash_digest}' # Getting cache key from request request_factory = RequestFactory() request = request_factory.post( '/admin/login/', data={ 'username': self.username, 'password': 'test', }, REMOTE_ADDR=empty_ip_address, ) self.assertEqual(cache_hash_key, get_client_cache_key(request)) # Getting cache key from AccessAttempt Object attempt = AccessAttempt( user_agent='', ip_address=empty_ip_address, username=self.username, get_data='', post_data='', http_accept=request.META.get('HTTP_ACCEPT', ''), path_info=request.META.get('PATH_INFO', ''), failures_since_start=0, ) self.assertEqual(cache_hash_key, get_client_cache_key(attempt)) def test_get_cache_key_credentials(self): """ Test the cache key format. """ ip_address = self.ip_address cache_hash_digest = md5(ip_address.encode()).hexdigest() cache_hash_key = f'axes-{cache_hash_digest}' # Getting cache key from request request_factory = RequestFactory() request = request_factory.post( '/admin/login/', data={ 'username': self.username, 'password': 'test' } ) # Difference between the upper test: new call signature with credentials credentials = {'username': self.username} self.assertEqual(cache_hash_key, get_client_cache_key(request, credentials)) # Getting cache key from AccessAttempt Object attempt = AccessAttempt( user_agent='', ip_address=ip_address, username=self.username, get_data='', post_data='', http_accept=request.META.get('HTTP_ACCEPT', ''), path_info=request.META.get('PATH_INFO', ''), failures_since_start=0, ) self.assertEqual(cache_hash_key, get_client_cache_key(attempt)) class UsernameTestCase(AxesTestCase): @override_settings(AXES_USERNAME_FORM_FIELD='username') def test_default_get_client_username(self): expected = 'test-username' request = HttpRequest() request.POST['username'] = expected actual = get_client_username(request) self.assertEqual(expected, actual) @override_settings(AXES_USERNAME_FORM_FIELD='username') def test_default_get_client_username_credentials(self): expected = 'test-username' expected_in_credentials = 'test-credentials-username' request = HttpRequest() request.POST['username'] = expected credentials = { 'username': expected_in_credentials } actual = get_client_username(request, credentials) self.assertEqual(expected_in_credentials, actual) def sample_customize_username(request, credentials): return 'prefixed-' + request.POST.get('username') @override_settings(AXES_USERNAME_FORM_FIELD='username') @override_settings(AXES_USERNAME_CALLABLE=sample_customize_username) def test_custom_get_client_username_from_request(self): provided = 'test-username' expected = 'prefixed-' + provided provided_in_credentials = 'test-credentials-username' request = HttpRequest() request.POST['username'] = provided credentials = {'username': provided_in_credentials} actual = get_client_username(request, credentials) self.assertEqual(expected, actual) def sample_customize_username_credentials(request, credentials): return 'prefixed-' + credentials.get('username') @override_settings(AXES_USERNAME_FORM_FIELD='username') @override_settings(AXES_USERNAME_CALLABLE=sample_customize_username_credentials) def test_custom_get_client_username_from_credentials(self): provided = 'test-username' provided_in_credentials = 'test-credentials-username' expected_in_credentials = 'prefixed-' + provided_in_credentials request = HttpRequest() request.POST['username'] = provided credentials = {'username': provided_in_credentials} actual = get_client_username(request, credentials) self.assertEqual(expected_in_credentials, actual) @override_settings(AXES_USERNAME_CALLABLE=lambda request, credentials: 'example') # pragma: no cover def test_get_client_username(self): self.assertEqual(get_client_username(HttpRequest(), {}), 'example') @override_settings(AXES_USERNAME_CALLABLE=lambda request: None) # pragma: no cover def test_get_client_username_invalid_callable_too_few_arguments(self): with self.assertRaises(TypeError): get_client_username(HttpRequest(), {}) @override_settings(AXES_USERNAME_CALLABLE=lambda request, credentials, extra: None) # pragma: no cover def test_get_client_username_invalid_callable_too_many_arguments(self): with self.assertRaises(TypeError): get_client_username(HttpRequest(), {}) @override_settings(AXES_USERNAME_CALLABLE=True) def test_get_client_username_not_callable(self): with self.assertRaises(TypeError): get_client_username(HttpRequest(), {}) @override_settings(AXES_USERNAME_CALLABLE='axes.tests.test_utils.get_username') def test_get_client_username_str(self): self.assertEqual( get_client_username(HttpRequest(), {}), 'username', ) def get_username(request, credentials: dict) -> str: return 'username' class IPWhitelistTestCase(AxesTestCase): def setUp(self): self.request = HttpRequest() self.request.method = 'POST' self.request.META['REMOTE_ADDR'] = '127.0.0.1' self.request.axes_ip_address = '127.0.0.1' @override_settings(AXES_IP_WHITELIST=None) def test_ip_in_whitelist_none(self): self.assertFalse(is_ip_address_in_whitelist('127.0.0.2')) @override_settings(AXES_IP_WHITELIST=['127.0.0.1']) def test_ip_in_whitelist(self): self.assertTrue(is_ip_address_in_whitelist('127.0.0.1')) self.assertFalse(is_ip_address_in_whitelist('127.0.0.2')) @override_settings(AXES_IP_BLACKLIST=None) def test_ip_in_blacklist_none(self): self.assertFalse(is_ip_address_in_blacklist('127.0.0.2')) @override_settings(AXES_IP_BLACKLIST=['127.0.0.1']) def test_ip_in_blacklist(self): self.assertTrue(is_ip_address_in_blacklist('127.0.0.1')) self.assertFalse(is_ip_address_in_blacklist('127.0.0.2')) @override_settings(AXES_IP_BLACKLIST=['127.0.0.1']) def test_is_client_ip_address_blacklisted_ip_in_blacklist(self): self.assertTrue(is_client_ip_address_blacklisted(self.request)) @override_settings(AXES_IP_BLACKLIST=['127.0.0.2']) def test_is_is_client_ip_address_blacklisted_ip_not_in_blacklist(self): self.assertFalse(is_client_ip_address_blacklisted(self.request)) @override_settings(AXES_NEVER_LOCKOUT_WHITELIST=True) @override_settings(AXES_IP_WHITELIST=['127.0.0.1']) def test_is_client_ip_address_blacklisted_ip_in_whitelist(self): self.assertFalse(is_client_ip_address_blacklisted(self.request)) @override_settings(AXES_ONLY_WHITELIST=True) @override_settings(AXES_IP_WHITELIST=['127.0.0.2']) def test_is_already_locked_ip_not_in_whitelist(self): self.assertTrue(is_client_ip_address_blacklisted(self.request)) @override_settings(AXES_NEVER_LOCKOUT_WHITELIST=True) @override_settings(AXES_IP_WHITELIST=['127.0.0.1']) def test_is_client_ip_address_whitelisted_never_lockout(self): self.assertTrue(is_client_ip_address_whitelisted(self.request)) @override_settings(AXES_ONLY_WHITELIST=True) @override_settings(AXES_IP_WHITELIST=['127.0.0.1']) def test_is_client_ip_address_whitelisted_only_allow(self): self.assertTrue(is_client_ip_address_whitelisted(self.request)) @override_settings(AXES_ONLY_WHITELIST=True) @override_settings(AXES_IP_WHITELIST=['127.0.0.2']) def test_is_client_ip_address_whitelisted_not(self): self.assertFalse(is_client_ip_address_whitelisted(self.request)) class MethodWhitelistTestCase(AxesTestCase): def setUp(self): self.request = HttpRequest() self.request.method = 'GET' @override_settings(AXES_NEVER_LOCKOUT_GET=True) def test_is_client_method_whitelisted(self): self.assertTrue(is_client_method_whitelisted(self.request)) @override_settings(AXES_NEVER_LOCKOUT_GET=False) def test_is_client_method_whitelisted_not(self): self.assertFalse(is_client_method_whitelisted(self.request)) class LockoutResponseTestCase(AxesTestCase): def setUp(self): self.request = HttpRequest() @override_settings(AXES_COOLOFF_TIME=42) def test_get_lockout_response_cool_off(self): get_lockout_response(request=self.request) @override_settings(AXES_LOCKOUT_TEMPLATE='example.html') @patch('axes.helpers.render') def test_get_lockout_response_lockout_template(self, render): self.assertFalse(render.called) get_lockout_response(request=self.request) self.assertTrue(render.called) @override_settings(AXES_LOCKOUT_URL='https://example.com') def test_get_lockout_response_lockout_url(self): response = get_lockout_response(request=self.request) self.assertEqual(type(response), HttpResponseRedirect) def test_get_lockout_response_lockout_json(self): self.request.is_ajax = lambda: True response = get_lockout_response(request=self.request) self.assertEqual(type(response), JsonResponse) def test_get_lockout_response_lockout_response(self): response = get_lockout_response(request=self.request) self.assertEqual(type(response), HttpResponse) django-axes-5.0.7/axes/tests/test_management.py0000644000175000017500000000416613500726664020552 0ustar jamesjamesfrom io import StringIO from django.core.management import call_command from axes.models import AccessAttempt from axes.tests.base import AxesTestCase class ManagementCommandTestCase(AxesTestCase): def setUp(self): AccessAttempt.objects.create( username='jane.doe', ip_address='10.0.0.1', failures_since_start='4', ) AccessAttempt.objects.create( username='john.doe', ip_address='10.0.0.2', failures_since_start='15', ) def test_axes_list_attempts(self): out = StringIO() call_command('axes_list_attempts', stdout=out) expected = '10.0.0.1\tjane.doe\t4\n10.0.0.2\tjohn.doe\t15\n' self.assertEqual(expected, out.getvalue()) def test_axes_reset(self): out = StringIO() call_command('axes_reset', stdout=out) expected = '2 attempts removed.\n' self.assertEqual(expected, out.getvalue()) def test_axes_reset_not_found(self): out = StringIO() call_command('axes_reset', stdout=out) out = StringIO() call_command('axes_reset', stdout=out) expected = 'No attempts found.\n' self.assertEqual(expected, out.getvalue()) def test_axes_reset_ip(self): out = StringIO() call_command('axes_reset_ip', '10.0.0.1', stdout=out) expected = '1 attempts removed.\n' self.assertEqual(expected, out.getvalue()) def test_axes_reset_ip_not_found(self): out = StringIO() call_command('axes_reset_ip', '10.0.0.3', stdout=out) expected = 'No attempts found.\n' self.assertEqual(expected, out.getvalue()) def test_axes_reset_username(self): out = StringIO() call_command('axes_reset_username', 'john.doe', stdout=out) expected = '1 attempts removed.\n' self.assertEqual(expected, out.getvalue()) def test_axes_reset_username_not_found(self): out = StringIO() call_command('axes_reset_username', 'ivan.renko', stdout=out) expected = 'No attempts found.\n' self.assertEqual(expected, out.getvalue()) django-axes-5.0.7/axes/tests/test_checks.py0000644000175000017500000000560013500726664017670 0ustar jamesjamesfrom django.core.checks import run_checks, Warning # pylint: disable=redefined-builtin from django.test import override_settings, modify_settings from axes.checks import Messages, Hints, Codes from axes.tests.base import AxesTestCase class CacheCheckTestCase(AxesTestCase): @override_settings( AXES_HANDLER='axes.handlers.cache.AxesCacheHandler', CACHES={'default': {'BACKEND': 'django.core.cache.backends.db.DatabaseCache', 'LOCATION': 'axes_cache'}}, ) def test_cache_check(self): warnings = run_checks() self.assertEqual(warnings, []) @override_settings( AXES_HANDLER='axes.handlers.cache.AxesCacheHandler', CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, ) def test_cache_check_warnings(self): warnings = run_checks() warning = Warning( msg=Messages.CACHE_INVALID, hint=Hints.CACHE_INVALID, id=Codes.CACHE_INVALID, ) self.assertEqual(warnings, [ warning, ]) @override_settings( AXES_HANDLER='axes.handlers.database.AxesDatabaseHandler', CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, ) def test_cache_check_does_not_produce_check_warnings_with_database_handler(self): warnings = run_checks() self.assertEqual(warnings, []) class MiddlewareCheckTestCase(AxesTestCase): @modify_settings( MIDDLEWARE={ 'remove': ['axes.middleware.AxesMiddleware'] }, ) def test_cache_check_warnings(self): warnings = run_checks() warning = Warning( msg=Messages.MIDDLEWARE_INVALID, hint=Hints.MIDDLEWARE_INVALID, id=Codes.MIDDLEWARE_INVALID, ) self.assertEqual(warnings, [ warning, ]) class BackendCheckTestCase(AxesTestCase): @modify_settings( AUTHENTICATION_BACKENDS={ 'remove': ['axes.backends.AxesBackend'] }, ) def test_cache_check_warnings(self): warnings = run_checks() warning = Warning( msg=Messages.BACKEND_INVALID, hint=Hints.BACKEND_INVALID, id=Codes.BACKEND_INVALID, ) self.assertEqual(warnings, [ warning, ]) class DeprecatedSettingsTestCase(AxesTestCase): def setUp(self): self.disable_success_access_log_warning = Warning( msg=Messages.SETTING_DEPRECATED.format(deprecated_setting='AXES_DISABLE_SUCCESS_ACCESS_LOG'), hint=Hints.SETTING_DEPRECATED, id=Codes.SETTING_DEPRECATED, ) @override_settings( AXES_DISABLE_SUCCESS_ACCESS_LOG=True, ) def test_deprecated_success_access_log_flag(self): warnings = run_checks() self.assertEqual(warnings, [ self.disable_success_access_log_warning, ]) django-axes-5.0.7/axes/tests/test_models.py0000644000175000017500000000221313500726664017710 0ustar jamesjamesfrom django.apps.registry import apps from django.db import connection from django.db.migrations.autodetector import MigrationAutodetector from django.db.migrations.executor import MigrationExecutor from django.db.migrations.state import ProjectState from axes.models import AccessAttempt, AccessLog from axes.tests.base import AxesTestCase class ModelsTestCase(AxesTestCase): def setUp(self): self.failures_since_start = 42 self.access_attempt = AccessAttempt( failures_since_start=self.failures_since_start, ) self.access_log = AccessLog() def test_access_attempt_str(self): self.assertIn('Access', str(self.access_attempt)) def test_access_log_str(self): self.assertIn('Access', str(self.access_log)) class MigrationsTestCase(AxesTestCase): def test_missing_migrations(self): executor = MigrationExecutor(connection) autodetector = MigrationAutodetector( executor.loader.project_state(), ProjectState.from_apps(apps), ) changes = autodetector.changes(graph=executor.loader.graph) self.assertEqual({}, changes) django-axes-5.0.7/axes/tests/test_signals.py0000644000175000017500000000075613500726664020077 0ustar jamesjamesfrom unittest.mock import MagicMock from axes.tests.base import AxesTestCase from axes.signals import user_locked_out class SignalTestCase(AxesTestCase): def test_send_lockout_signal(self): """ Test if the lockout signal is correctly emitted when user is locked out. """ handler = MagicMock() user_locked_out.connect(handler) self.assertEqual(0, handler.call_count) self.lockout() self.assertEqual(1, handler.call_count) django-axes-5.0.7/axes/tests/test_middleware.py0000644000175000017500000000157513500726664020554 0ustar jamesjamesfrom unittest.mock import patch, MagicMock from django.http import HttpResponse, HttpRequest from axes.middleware import AxesMiddleware from axes.tests.base import AxesTestCase class MiddlewareTestCase(AxesTestCase): STATUS_SUCCESS = 200 STATUS_LOCKOUT = 403 def setUp(self): self.request = HttpRequest() def test_success_response(self): def get_response(request): request.axes_locked_out = False return HttpResponse() response = AxesMiddleware(get_response)(self.request) self.assertEqual(response.status_code, self.STATUS_SUCCESS) def test_lockout_response(self): def get_response(request): request.axes_locked_out = True return HttpResponse() response = AxesMiddleware(get_response)(self.request) self.assertEqual(response.status_code, self.STATUS_LOCKOUT) django-axes-5.0.7/axes/handlers/0000755000175000017500000000000013500726664015454 5ustar jamesjamesdjango-axes-5.0.7/axes/handlers/dummy.py0000644000175000017500000000050113500726664017155 0ustar jamesjamesfrom axes.handlers.base import AxesHandler class AxesDummyHandler(AxesHandler): # pylint: disable=unused-argument """ Signal handler implementation that does nothing and can be used to disable signal processing. """ def is_allowed(self, request, credentials: dict = None) -> bool: return True django-axes-5.0.7/axes/handlers/cache.py0000644000175000017500000000760413500726664017100 0ustar jamesjamesfrom logging import getLogger from axes.conf import settings from axes.handlers.base import AxesHandler from axes.signals import user_locked_out from axes.helpers import ( get_cache, get_cache_timeout, get_client_cache_key, get_client_str, get_client_username, get_credentials, get_failure_limit, ) log = getLogger(settings.AXES_LOGGER) class AxesCacheHandler(AxesHandler): # pylint: disable=too-many-locals """ Signal handler implementation that records user login attempts to cache and locks users out if necessary. """ def __init__(self): self.cache = get_cache() self.cache_timeout = get_cache_timeout() def get_failures(self, request, credentials: dict = None) -> int: cache_key = get_client_cache_key(request, credentials) return self.cache.get(cache_key, default=0) def user_login_failed( self, sender, credentials: dict, request = None, **kwargs ): # pylint: disable=too-many-locals """ When user login fails, save attempt record in cache and lock user out if necessary. :raises AxesSignalPermissionDenied: if user should be locked out. """ if request is None: log.error('AXES: AxesCacheHandler.user_login_failed does not function without a request.') return username = get_client_username(request, credentials) client_str = get_client_str(username, request.axes_ip_address, request.axes_user_agent, request.axes_path_info) if self.is_whitelisted(request, credentials): log.info('AXES: Login failed from whitelisted client %s.', client_str) return failures_since_start = 1 + self.get_failures(request, credentials) if failures_since_start > 1: log.warning( 'AXES: Repeated login failure by %s. Count = %d of %d. Updating existing record in the cache.', client_str, failures_since_start, get_failure_limit(request, credentials), ) else: log.warning( 'AXES: New login failure by %s. Creating new record in the cache.', client_str, ) cache_key = get_client_cache_key(request, credentials) self.cache.set(cache_key, failures_since_start, self.cache_timeout) if settings.AXES_LOCK_OUT_AT_FAILURE and failures_since_start >= get_failure_limit(request, credentials): log.warning('AXES: Locking out %s after repeated login failures.', client_str) request.axes_locked_out = True user_locked_out.send( 'axes', request=request, username=username, ip_address=request.axes_ip_address, ) def user_logged_in(self, sender, request, user, **kwargs): # pylint: disable=unused-argument """ When user logs in, update the AccessLog related to the user. """ username = user.get_username() credentials = get_credentials(username) client_str = get_client_str(username, request.axes_ip_address, request.axes_user_agent, request.axes_path_info) log.info('AXES: Successful login by %s.', client_str) if settings.AXES_RESET_ON_SUCCESS: cache_key = get_client_cache_key(request, credentials) failures_since_start = self.cache.get(cache_key, default=0) self.cache.delete(cache_key) log.info('AXES: Deleted %d failed login attempts by %s from cache.', failures_since_start, client_str) def user_logged_out(self, sender, request, user, **kwargs): username = user.get_username() if user else None client_str = get_client_str(username, request.axes_ip_address, request.axes_user_agent, request.axes_path_info) log.info('AXES: Successful logout by %s.', client_str) django-axes-5.0.7/axes/handlers/__init__.py0000644000175000017500000000000013500726664017553 0ustar jamesjamesdjango-axes-5.0.7/axes/handlers/base.py0000644000175000017500000001032513500726664016741 0ustar jamesjamesfrom axes.conf import settings from axes.helpers import ( get_failure_limit, is_client_ip_address_blacklisted, is_client_ip_address_whitelisted, is_client_method_whitelisted, ) class AxesHandler: # pylint: disable=unused-argument """ Handler API definition for implementations that are used by the ``AxesProxyHandler``. If you wish to specialize your own handler class, override the necessary methods and configure the class for use by setting ``settings.AXES_HANDLER = 'module.path.to.YourClass'``. The default implementation that is actually used by Axes is ``axes.handlers.database.AxesDatabaseHandler``. .. note:: This is a virtual class and **can not be used without specialization**. """ def is_allowed(self, request, credentials: dict = None) -> bool: """ Checks if the user is allowed to access or use given functionality such as a login view or authentication. This method is abstract and other backends can specialize it as needed, but the default implementation checks if the user has attempted to authenticate into the site too many times through the Django authentication backends and returns ``False`` if user exceeds the configured Axes thresholds. This checker can implement arbitrary checks such as IP whitelisting or blacklisting, request frequency checking, failed attempt monitoring or similar functions. Please refer to the ``axes.handlers.database.AxesDatabaseHandler`` for the default implementation and inspiration on some common checks and access restrictions before writing your own implementation. """ if self.is_blacklisted(request, credentials): return False if self.is_whitelisted(request, credentials): return True if self.is_locked(request, credentials): return False return True def user_login_failed(self, sender, credentials: dict, request = None, **kwargs): """ Handles the Django ``django.contrib.auth.signals.user_login_failed`` authentication signal. """ def user_logged_in(self, sender, request, user, **kwargs): """ Handles the Django ``django.contrib.auth.signals.user_logged_in`` authentication signal. """ def user_logged_out(self, sender, request, user, **kwargs): """ Handles the Django ``django.contrib.auth.signals.user_logged_out`` authentication signal. """ def post_save_access_attempt(self, instance, **kwargs): """ Handles the ``axes.models.AccessAttempt`` object post save signal. """ def post_delete_access_attempt(self, instance, **kwargs): """ Handles the ``axes.models.AccessAttempt`` object post delete signal. """ def is_blacklisted(self, request, credentials: dict = None) -> bool: # pylint: disable=unused-argument """ Checks if the request or given credentials are blacklisted from access. """ if is_client_ip_address_blacklisted(request): return True return False def is_whitelisted(self, request, credentials: dict = None) -> bool: # pylint: disable=unused-argument """ Checks if the request or given credentials are whitelisted for access. """ if is_client_ip_address_whitelisted(request): return True if is_client_method_whitelisted(request): return True return False def is_locked(self, request, credentials: dict = None) -> bool: """ Checks if the request or given credentials are locked. """ if settings.AXES_LOCK_OUT_AT_FAILURE: return self.get_failures(request, credentials) >= get_failure_limit(request, credentials) return False def get_failures(self, request, credentials: dict = None) -> int: """ Checks the number of failures associated to the given request and credentials. This is a virtual method that needs an implementation in the handler subclass if the ``settings.AXES_LOCK_OUT_AT_FAILURE`` flag is set to ``True``. """ raise NotImplementedError('The Axes handler class needs a method definition for get_failures') django-axes-5.0.7/axes/handlers/proxy.py0000644000175000017500000000662613500726664017221 0ustar jamesjamesfrom logging import getLogger from django.utils.module_loading import import_string from django.utils.timezone import now from axes.conf import settings from axes.handlers.base import AxesHandler from axes.helpers import ( get_client_ip_address, get_client_user_agent, get_client_path_info, get_client_http_accept, toggleable, ) log = getLogger(settings.AXES_LOGGER) class AxesProxyHandler(AxesHandler): """ Proxy interface for configurable Axes signal handler class. If you wish to implement a custom version of this handler, you can override the settings.AXES_HANDLER configuration string with a class that implements a compatible interface and methods. Defaults to using axes.handlers.proxy.AxesProxyHandler if not overridden. Refer to axes.handlers.proxy.AxesProxyHandler for default implementation. """ implementation = None # type: AxesHandler @classmethod def get_implementation(cls, force: bool = False) -> AxesHandler: """ Fetch and initialize configured handler implementation and memoize it to avoid reinitialization. This method is re-entrant and can be called multiple times from e.g. Django application loader. """ if force or not cls.implementation: cls.implementation = import_string(settings.AXES_HANDLER)() return cls.implementation @staticmethod def update_request(request): """ Update request attributes before passing them into the selected handler class. """ if request is None: log.error('AXES: AxesProxyHandler.update_request can not set request attributes to a None request') return request.axes_locked_out = False request.axes_attempt_time = now() request.axes_ip_address = get_client_ip_address(request) request.axes_user_agent = get_client_user_agent(request) request.axes_path_info = get_client_path_info(request) request.axes_http_accept = get_client_http_accept(request) @classmethod def is_locked(cls, request, credentials: dict = None) -> bool: cls.update_request(request) return cls.get_implementation().is_locked(request, credentials) @classmethod def is_allowed(cls, request, credentials: dict = None) -> bool: cls.update_request(request) return cls.get_implementation().is_allowed(request, credentials) @classmethod @toggleable def user_login_failed(cls, sender, credentials: dict, request=None, **kwargs): cls.update_request(request) return cls.get_implementation().user_login_failed(sender, credentials, request, **kwargs) @classmethod @toggleable def user_logged_in(cls, sender, request, user, **kwargs): cls.update_request(request) return cls.get_implementation().user_logged_in(sender, request, user, **kwargs) @classmethod @toggleable def user_logged_out(cls, sender, request, user, **kwargs): cls.update_request(request) return cls.get_implementation().user_logged_out(sender, request, user, **kwargs) @classmethod @toggleable def post_save_access_attempt(cls, instance, **kwargs): return cls.get_implementation().post_save_access_attempt(instance, **kwargs) @classmethod def post_delete_access_attempt(cls, instance, **kwargs): return cls.get_implementation().post_delete_access_attempt(instance, **kwargs) django-axes-5.0.7/axes/handlers/database.py0000644000175000017500000001636113500726664017601 0ustar jamesjamesfrom logging import getLogger from django.db.models import Max, Value from django.db.models.functions import Concat from axes.attempts import ( clean_expired_user_attempts, get_user_attempts, is_user_attempt_whitelisted, reset_user_attempts, ) from axes.conf import settings from axes.handlers.base import AxesHandler from axes.models import AccessLog, AccessAttempt from axes.signals import user_locked_out from axes.helpers import ( get_client_str, get_client_username, get_credentials, get_failure_limit, get_query_str, ) log = getLogger(settings.AXES_LOGGER) class AxesDatabaseHandler(AxesHandler): # pylint: disable=too-many-locals """ Signal handler implementation that records user login attempts to database and locks users out if necessary. """ def get_failures(self, request, credentials: dict = None) -> int: attempts = get_user_attempts(request, credentials) return attempts.aggregate(Max('failures_since_start'))['failures_since_start__max'] or 0 def is_locked(self, request, credentials: dict = None): if is_user_attempt_whitelisted(request, credentials): return False return super().is_locked(request, credentials) def user_login_failed( self, sender, credentials: dict, request = None, **kwargs ): # pylint: disable=too-many-locals """ When user login fails, save AccessAttempt record in database and lock user out if necessary. :raises AxesSignalPermissionDenied: if user should be locked out. """ if request is None: log.error('AXES: AxesDatabaseHandler.user_login_failed does not function without a request.') return # 1. database query: Clean up expired user attempts from the database before logging new attempts clean_expired_user_attempts(request.axes_attempt_time) username = get_client_username(request, credentials) client_str = get_client_str(username, request.axes_ip_address, request.axes_user_agent, request.axes_path_info) get_data = get_query_str(request.GET) post_data = get_query_str(request.POST) if self.is_whitelisted(request, credentials): log.info('AXES: Login failed from whitelisted client %s.', client_str) return # 2. database query: Calculate the current maximum failure number from the existing attempts failures_since_start = 1 + self.get_failures(request, credentials) # 3. database query: Insert or update access records with the new failure data if failures_since_start > 1: # Update failed attempt information but do not touch the username, IP address, or user agent fields, # because attackers can request the site with multiple different configurations # in order to bypass the defense mechanisms that are used by the site. log.warning( 'AXES: Repeated login failure by %s. Count = %d of %d. Updating existing record in the database.', client_str, failures_since_start, get_failure_limit(request, credentials), ) separator = '\n---------\n' attempts = get_user_attempts(request, credentials) attempts.update( get_data=Concat('get_data', Value(separator + get_data)), post_data=Concat('post_data', Value(separator + post_data)), http_accept=request.axes_http_accept, path_info=request.axes_path_info, failures_since_start=failures_since_start, attempt_time=request.axes_attempt_time, ) else: # Record failed attempt with all the relevant information. # Filtering based on username, IP address and user agent handled elsewhere, # and this handler just records the available information for further use. log.warning( 'AXES: New login failure by %s. Creating new record in the database.', client_str, ) AccessAttempt.objects.create( username=username, ip_address=request.axes_ip_address, user_agent=request.axes_user_agent, get_data=get_data, post_data=post_data, http_accept=request.axes_http_accept, path_info=request.axes_path_info, failures_since_start=failures_since_start, attempt_time=request.axes_attempt_time, ) if settings.AXES_LOCK_OUT_AT_FAILURE and failures_since_start >= get_failure_limit(request, credentials): log.warning('AXES: Locking out %s after repeated login failures.', client_str) request.axes_locked_out = True user_locked_out.send( 'axes', request=request, username=username, ip_address=request.axes_ip_address, ) def user_logged_in(self, sender, request, user, **kwargs): # pylint: disable=unused-argument """ When user logs in, update the AccessLog related to the user. """ # 1. database query: Clean up expired user attempts from the database clean_expired_user_attempts(request.axes_attempt_time) username = user.get_username() credentials = get_credentials(username) client_str = get_client_str(username, request.axes_ip_address, request.axes_user_agent, request.axes_path_info) log.info('AXES: Successful login by %s.', client_str) if not settings.AXES_DISABLE_ACCESS_LOG: # 2. database query: Insert new access logs with login time AccessLog.objects.create( username=username, ip_address=request.axes_ip_address, user_agent=request.axes_user_agent, http_accept=request.axes_http_accept, path_info=request.axes_path_info, attempt_time=request.axes_attempt_time, ) if settings.AXES_RESET_ON_SUCCESS: # 3. database query: Reset failed attempts for the logging in user count = reset_user_attempts(request, credentials) log.info('AXES: Deleted %d failed login attempts by %s from database.', count, client_str) def user_logged_out(self, sender, request, user, **kwargs): # pylint: disable=unused-argument """ When user logs out, update the AccessLog related to the user. """ # 1. database query: Clean up expired user attempts from the database clean_expired_user_attempts(request.axes_attempt_time) username = user.get_username() if user else None client_str = get_client_str(username, request.axes_ip_address, request.axes_user_agent, request.axes_path_info) log.info('AXES: Successful logout by %s.', client_str) if username and not settings.AXES_DISABLE_ACCESS_LOG: # 2. database query: Update existing attempt logs with logout time AccessLog.objects.filter( username=username, logout_time__isnull=True, ).update( logout_time=request.axes_attempt_time, ) django-axes-5.0.7/axes/admin.py0000644000175000017500000000412213500726664015315 0ustar jamesjamesfrom django.contrib import admin from django.utils.translation import gettext_lazy as _ from axes.models import AccessAttempt, AccessLog @admin.register(AccessAttempt) class AccessAttemptAdmin(admin.ModelAdmin): list_display = ( 'attempt_time', 'ip_address', 'user_agent', 'username', 'path_info', 'failures_since_start', ) list_filter = [ 'attempt_time', 'path_info', ] search_fields = [ 'ip_address', 'username', 'user_agent', 'path_info', ] date_hierarchy = 'attempt_time' fieldsets = ( (None, { 'fields': ('path_info', 'failures_since_start') }), (_('Form Data'), { 'fields': ('get_data', 'post_data') }), (_('Meta Data'), { 'fields': ('user_agent', 'ip_address', 'http_accept') }) ) readonly_fields = [ 'user_agent', 'ip_address', 'username', 'http_accept', 'path_info', 'attempt_time', 'get_data', 'post_data', 'failures_since_start' ] def has_add_permission(self, request): return False @admin.register(AccessLog) class AccessLogAdmin(admin.ModelAdmin): list_display = ( 'attempt_time', 'logout_time', 'ip_address', 'username', 'user_agent', 'path_info', ) list_filter = [ 'attempt_time', 'logout_time', 'path_info', ] search_fields = [ 'ip_address', 'user_agent', 'username', 'path_info', ] date_hierarchy = 'attempt_time' fieldsets = ( (None, { 'fields': ('path_info',) }), (_('Meta Data'), { 'fields': ('user_agent', 'ip_address', 'http_accept') }) ) readonly_fields = [ 'user_agent', 'ip_address', 'username', 'http_accept', 'path_info', 'attempt_time', 'logout_time' ] def has_add_permission(self, request): return False django-axes-5.0.7/axes/decorators.py0000644000175000017500000000114313500726664016372 0ustar jamesjamesfrom functools import wraps from axes.handlers.proxy import AxesProxyHandler from axes.helpers import get_lockout_response def axes_dispatch(func): def inner(request, *args, **kwargs): if AxesProxyHandler.is_allowed(request): return func(request, *args, **kwargs) return get_lockout_response(request) return inner def axes_form_invalid(func): @wraps(func) def inner(self, *args, **kwargs): if AxesProxyHandler.is_allowed(self.request): return func(self, *args, **kwargs) return get_lockout_response(self.request) return inner django-axes-5.0.7/axes/migrations/0000755000175000017500000000000013500726664016030 5ustar jamesjamesdjango-axes-5.0.7/axes/migrations/0002_auto_20151217_2044.py0000644000175000017500000000321613500726664021450 0ustar jamesjamesfrom django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('axes', '0001_initial'), ] operations = [ migrations.AlterField( model_name='accessattempt', name='ip_address', field=models.GenericIPAddressField(db_index=True, null=True, verbose_name='IP Address'), ), migrations.AlterField( model_name='accessattempt', name='trusted', field=models.BooleanField(db_index=True, default=False), ), migrations.AlterField( model_name='accessattempt', name='user_agent', field=models.CharField(db_index=True, max_length=255), ), migrations.AlterField( model_name='accessattempt', name='username', field=models.CharField(db_index=True, max_length=255, null=True), ), migrations.AlterField( model_name='accesslog', name='ip_address', field=models.GenericIPAddressField(db_index=True, null=True, verbose_name='IP Address'), ), migrations.AlterField( model_name='accesslog', name='trusted', field=models.BooleanField(db_index=True, default=False), ), migrations.AlterField( model_name='accesslog', name='user_agent', field=models.CharField(db_index=True, max_length=255), ), migrations.AlterField( model_name='accesslog', name='username', field=models.CharField(db_index=True, max_length=255, null=True), ), ] django-axes-5.0.7/axes/migrations/0004_auto_20181024_1538.py0000644000175000017500000000366013500726664021463 0ustar jamesjamesfrom django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('axes', '0003_auto_20160322_0929'), ] operations = [ migrations.AlterModelOptions( name='accessattempt', options={'verbose_name': 'access attempt', 'verbose_name_plural': 'access attempts'}, ), migrations.AlterModelOptions( name='accesslog', options={'verbose_name': 'access log', 'verbose_name_plural': 'access logs'}, ), migrations.AlterField( model_name='accessattempt', name='attempt_time', field=models.DateTimeField(auto_now_add=True, verbose_name='Attempt Time'), ), migrations.AlterField( model_name='accessattempt', name='user_agent', field=models.CharField(db_index=True, max_length=255, verbose_name='User Agent'), ), migrations.AlterField( model_name='accessattempt', name='username', field=models.CharField(db_index=True, max_length=255, null=True, verbose_name='Username'), ), migrations.AlterField( model_name='accesslog', name='attempt_time', field=models.DateTimeField(auto_now_add=True, verbose_name='Attempt Time'), ), migrations.AlterField( model_name='accesslog', name='logout_time', field=models.DateTimeField(blank=True, null=True, verbose_name='Logout Time'), ), migrations.AlterField( model_name='accesslog', name='user_agent', field=models.CharField(db_index=True, max_length=255, verbose_name='User Agent'), ), migrations.AlterField( model_name='accesslog', name='username', field=models.CharField(db_index=True, max_length=255, null=True, verbose_name='Username'), ), ] django-axes-5.0.7/axes/migrations/__init__.py0000644000175000017500000000000013500726664020127 0ustar jamesjamesdjango-axes-5.0.7/axes/migrations/0003_auto_20160322_0929.py0000644000175000017500000000355713500726664021470 0ustar jamesjamesfrom django.db import models, migrations class Migration(migrations.Migration): dependencies = [ ('axes', '0002_auto_20151217_2044'), ] operations = [ migrations.AlterField( model_name='accessattempt', name='failures_since_start', field=models.PositiveIntegerField(verbose_name='Failed Logins'), ), migrations.AlterField( model_name='accessattempt', name='get_data', field=models.TextField(verbose_name='GET Data'), ), migrations.AlterField( model_name='accessattempt', name='http_accept', field=models.CharField(verbose_name='HTTP Accept', max_length=1025), ), migrations.AlterField( model_name='accessattempt', name='ip_address', field=models.GenericIPAddressField(null=True, verbose_name='IP Address', db_index=True), ), migrations.AlterField( model_name='accessattempt', name='path_info', field=models.CharField(verbose_name='Path', max_length=255), ), migrations.AlterField( model_name='accessattempt', name='post_data', field=models.TextField(verbose_name='POST Data'), ), migrations.AlterField( model_name='accesslog', name='http_accept', field=models.CharField(verbose_name='HTTP Accept', max_length=1025), ), migrations.AlterField( model_name='accesslog', name='ip_address', field=models.GenericIPAddressField(null=True, verbose_name='IP Address', db_index=True), ), migrations.AlterField( model_name='accesslog', name='path_info', field=models.CharField(verbose_name='Path', max_length=255), ), ] django-axes-5.0.7/axes/migrations/0001_initial.py0000644000175000017500000000434713500726664020503 0ustar jamesjamesfrom django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ] operations = [ migrations.CreateModel( name='AccessAttempt', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('user_agent', models.CharField(max_length=255)), ('ip_address', models.GenericIPAddressField(null=True, verbose_name='IP Address')), ('username', models.CharField(max_length=255, null=True)), ('trusted', models.BooleanField(default=False)), ('http_accept', models.CharField(max_length=1025, verbose_name='HTTP Accept')), ('path_info', models.CharField(max_length=255, verbose_name='Path')), ('attempt_time', models.DateTimeField(auto_now_add=True)), ('get_data', models.TextField(verbose_name='GET Data')), ('post_data', models.TextField(verbose_name='POST Data')), ('failures_since_start', models.PositiveIntegerField(verbose_name='Failed Logins')), ], options={ 'ordering': ['-attempt_time'], 'abstract': False, }, ), migrations.CreateModel( name='AccessLog', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('user_agent', models.CharField(max_length=255)), ('ip_address', models.GenericIPAddressField(null=True, verbose_name='IP Address')), ('username', models.CharField(max_length=255, null=True)), ('trusted', models.BooleanField(default=False)), ('http_accept', models.CharField(max_length=1025, verbose_name='HTTP Accept')), ('path_info', models.CharField(max_length=255, verbose_name='Path')), ('attempt_time', models.DateTimeField(auto_now_add=True)), ('logout_time', models.DateTimeField(null=True, blank=True)), ], options={ 'ordering': ['-attempt_time'], 'abstract': False, }, ), ] django-axes-5.0.7/axes/migrations/0006_remove_accesslog_trusted.py0000644000175000017500000000052313500726664024141 0ustar jamesjames# Generated by Django 2.0.4 on 2019-03-13 08:55 from django.db import migrations class Migration(migrations.Migration): dependencies = [ ('axes', '0005_remove_accessattempt_trusted'), ] operations = [ migrations.RemoveField( model_name='accesslog', name='trusted', ), ] django-axes-5.0.7/axes/migrations/0005_remove_accessattempt_trusted.py0000644000175000017500000000043413500726664025036 0ustar jamesjamesfrom django.db import migrations class Migration(migrations.Migration): dependencies = [ ('axes', '0004_auto_20181024_1538'), ] operations = [ migrations.RemoveField( model_name='accessattempt', name='trusted', ), ] django-axes-5.0.7/axes/management/0000755000175000017500000000000013500726664015770 5ustar jamesjamesdjango-axes-5.0.7/axes/management/__init__.py0000644000175000017500000000000013500726664020067 0ustar jamesjamesdjango-axes-5.0.7/axes/management/commands/0000755000175000017500000000000013500726664017571 5ustar jamesjamesdjango-axes-5.0.7/axes/management/commands/axes_reset.py0000644000175000017500000000063113500726664022305 0ustar jamesjamesfrom django.core.management.base import BaseCommand from axes.utils import reset class Command(BaseCommand): help = 'Reset all access attempts and lockouts' def handle(self, *args, **options): # pylint: disable=unused-argument count = reset() if count: self.stdout.write(f'{count} attempts removed.') else: self.stdout.write('No attempts found.') django-axes-5.0.7/axes/management/commands/__init__.py0000644000175000017500000000000013500726664021670 0ustar jamesjamesdjango-axes-5.0.7/axes/management/commands/axes_reset_ip.py0000644000175000017500000000105013500726664022771 0ustar jamesjamesfrom django.core.management.base import BaseCommand from axes.utils import reset class Command(BaseCommand): help = 'Reset all access attempts and lockouts for given IP addresses' def add_arguments(self, parser): parser.add_argument('ip', nargs='+', type=str) def handle(self, *args, **options): count = 0 for ip in options['ip']: count += reset(ip=ip) if count: self.stdout.write(f'{count} attempts removed.') else: self.stdout.write('No attempts found.') django-axes-5.0.7/axes/management/commands/axes_reset_user.py0000777000175000017500000000000013500726664027753 2axes_reset_username.pyustar jamesjamesdjango-axes-5.0.7/axes/management/commands/axes_list_attempts.py0000644000175000017500000000056613500726664024066 0ustar jamesjamesfrom django.core.management.base import BaseCommand from axes.models import AccessAttempt class Command(BaseCommand): help = 'List access attempts' def handle(self, *args, **options): # pylint: disable=unused-argument for obj in AccessAttempt.objects.all(): self.stdout.write(f'{obj.ip_address}\t{obj.username}\t{obj.failures_since_start}') django-axes-5.0.7/axes/management/commands/axes_reset_username.py0000644000175000017500000000110313500726664024177 0ustar jamesjamesfrom django.core.management.base import BaseCommand from axes.utils import reset class Command(BaseCommand): help = 'Reset all access attempts and lockouts for given usernames' def add_arguments(self, parser): parser.add_argument('username', nargs='+', type=str) def handle(self, *args, **options): count = 0 for username in options['username']: count += reset(username=username) if count: self.stdout.write(f'{count} attempts removed.') else: self.stdout.write('No attempts found.') django-axes-5.0.7/axes/backends.py0000644000175000017500000000525013500726664016002 0ustar jamesjamesfrom django.contrib.auth.backends import ModelBackend from axes.exceptions import AxesBackendPermissionDenied, AxesBackendRequestParameterRequired from axes.handlers.proxy import AxesProxyHandler from axes.helpers import get_credentials, get_lockout_message, toggleable class AxesBackend(ModelBackend): """ Authentication backend class that forbids login attempts for locked out users. Use this class as the first item of ``AUTHENTICATION_BACKENDS`` to prevent locked out users from being logged in by the Django authentication flow. .. note:: This backend does not log your user in. It monitors login attempts. Authentication is handled by the following backends that are configured in ``AUTHENTICATION_BACKENDS``. """ @toggleable def authenticate(self, request, username: str = None, password: str = None, **kwargs: dict): """ Checks user lockout status and raises an exception if user is not allowed to log in. This method interrupts the login flow and inserts error message directly to the ``response_context`` attribute that is supplied as a keyword argument. :keyword response_context: kwarg that will be have its ``error`` attribute updated with context. :raises AxesBackendRequestParameterRequired: if request parameter is not passed. :raises AxesBackendPermissionDenied: if user is already locked out. """ if request is None: raise AxesBackendRequestParameterRequired('AxesBackend requires a request as an argument to authenticate') credentials = get_credentials(username=username, password=password, **kwargs) if AxesProxyHandler.is_allowed(request, credentials): return # Locked out, don't try to authenticate, just update response_context and return. # Its a bit weird to pass a context and expect a response value but its nice to get a "why" back. error_msg = get_lockout_message() response_context = kwargs.get('response_context', {}) response_context['error'] = error_msg # Raise an error that stops the authentication flows at django.contrib.auth.authenticate. # This error stops bubbling up at the authenticate call which catches backend PermissionDenied errors. # After this error is caught by authenticate it emits a signal indicating user login failed, # which is processed by axes.signals.log_user_login_failed which logs and flags the failed request. # The axes.middleware.AxesMiddleware further processes the flagged request into a readable response. raise AxesBackendPermissionDenied('AxesBackend detected that the given user is locked out') django-axes-5.0.7/axes/helpers.py0000644000175000017500000003223413500726664015674 0ustar jamesjamesfrom datetime import timedelta from hashlib import md5 from logging import getLogger from typing import Any, Callable, Optional, Type, Union from django.core.cache import caches, BaseCache from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse, QueryDict from django.shortcuts import render from django.utils.module_loading import import_string import ipware.ip2 from axes.conf import settings log = getLogger(__name__) def get_cache() -> BaseCache: """ Get the cache instance Axes is configured to use with ``settings.AXES_CACHE`` and use ``'default'`` if not set. """ return caches[getattr(settings, 'AXES_CACHE', 'default')] def get_cache_timeout() -> Optional[int]: """ Return the cache timeout interpreted from settings.AXES_COOLOFF_TIME. The cache timeout can be either None if not configured or integer of seconds if configured. Notice that the settings.AXES_COOLOFF_TIME can be None, timedelta, or integer of hours, and this function offers a unified _integer or None_ representation of that configuration for use with the Django cache backends. """ cool_off = get_cool_off() if cool_off is None: return None return int(cool_off.total_seconds()) def get_cool_off() -> Optional[timedelta]: """ Return the login cool off time interpreted from settings.AXES_COOLOFF_TIME. The return value is either None or timedelta. Notice that the settings.AXES_COOLOFF_TIME is either None, timedelta, or integer of hours, and this function offers a unified _timedelta or None_ representation of that configuration for use with the Axes internal implementations. :exception TypeError: if settings.AXES_COOLOFF_TIME is of wrong type. """ cool_off = settings.AXES_COOLOFF_TIME if isinstance(cool_off, int): return timedelta(hours=cool_off) return cool_off def get_cool_off_iso8601(delta: timedelta) -> str: """ Return datetime.timedelta translated to ISO 8601 formatted duration for use in e.g. cool offs. """ seconds = delta.total_seconds() minutes, seconds = divmod(seconds, 60) hours, minutes = divmod(minutes, 60) days, hours = divmod(hours, 24) days_str = f'{days:.0f}D' if days else '' time_str = ''.join( f'{value:.0f}{designator}' for value, designator in [ [hours, 'H'], [minutes, 'M'], [seconds, 'S'], ] if value ) if time_str: return f'P{days_str}T{time_str}' return f'P{days_str}' def get_credentials(username: str = None, **kwargs) -> dict: """ Calculate credentials for Axes to use internally from given username and kwargs. Axes will set the username value into the key defined with ``settings.AXES_USERNAME_FORM_FIELD`` and update the credentials dictionary with the kwargs given on top of that. """ credentials = {settings.AXES_USERNAME_FORM_FIELD: username} credentials.update(kwargs) return credentials def get_client_username(request, credentials: dict = None) -> str: """ Resolve client username from the given request or credentials if supplied. The order of preference for fetching the username is as follows: 1. If configured, use ``AXES_USERNAME_CALLABLE``, and supply ``request, credentials`` as arguments 2. If given, use ``credentials`` and fetch username from ``AXES_USERNAME_FORM_FIELD`` (defaults to ``username``) 3. Use request.POST and fetch username from ``AXES_USERNAME_FORM_FIELD`` (defaults to ``username``) :param request: incoming Django ``HttpRequest`` or similar object from authentication backend or other source :param credentials: incoming credentials ``dict`` or similar object from authentication backend or other source """ if settings.AXES_USERNAME_CALLABLE: log.debug('Using settings.AXES_USERNAME_CALLABLE to get username') if callable(settings.AXES_USERNAME_CALLABLE): return settings.AXES_USERNAME_CALLABLE(request, credentials) if isinstance(settings.AXES_USERNAME_CALLABLE, str): return import_string(settings.AXES_USERNAME_CALLABLE)(request, credentials) raise TypeError('settings.AXES_USERNAME_CALLABLE needs to be a string, callable, or None.') if credentials: log.debug('Using parameter credentials to get username with key settings.AXES_USERNAME_FORM_FIELD') return credentials.get(settings.AXES_USERNAME_FORM_FIELD, None) log.debug('Using parameter request.POST to get username with key settings.AXES_USERNAME_FORM_FIELD') return request.POST.get(settings.AXES_USERNAME_FORM_FIELD, None) def get_client_ip_address(request) -> str: """ Get client IP address as configured by the user. The django-ipware package is used for address resolution and parameters can be configured in the Axes package. """ client_ip_address, _ = ipware.ip2.get_client_ip( request, proxy_order=settings.AXES_PROXY_ORDER, proxy_count=settings.AXES_PROXY_COUNT, proxy_trusted_ips=settings.AXES_PROXY_TRUSTED_IPS, request_header_order=settings.AXES_META_PRECEDENCE_ORDER, ) return client_ip_address def get_client_user_agent(request) -> str: return request.META.get('HTTP_USER_AGENT', '')[:255] def get_client_path_info(request) -> str: return request.META.get('PATH_INFO', '')[:255] def get_client_http_accept(request) -> str: return request.META.get('HTTP_ACCEPT', '')[:1025] def get_client_parameters(username: str, ip_address: str, user_agent: str) -> dict: """ Get query parameters for filtering AccessAttempt queryset. This method returns a dict that guarantees iteration order for keys and values, and can so be used in e.g. the generation of hash keys or other deterministic functions. """ filter_kwargs = dict() if settings.AXES_ONLY_USER_FAILURES: # 1. Only individual usernames can be tracked with parametrization filter_kwargs['username'] = username else: if settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP: # 2. A combination of username and IP address can be used as well filter_kwargs['username'] = username filter_kwargs['ip_address'] = ip_address else: # 3. Default case is to track the IP address only, which is the most secure option filter_kwargs['ip_address'] = ip_address if settings.AXES_USE_USER_AGENT: # 4. The HTTP User-Agent can be used to track e.g. one browser filter_kwargs['user_agent'] = user_agent return filter_kwargs def get_client_str(username: str, ip_address: str, user_agent: str, path_info: str) -> str: """ Get a readable string that can be used in e.g. logging to distinguish client requests. Example log format would be ``{username: "example", ip_address: "127.0.0.1", path_info: "/example/"}`` """ client_dict = dict() if settings.AXES_VERBOSE: # Verbose mode logs every attribute that is available client_dict['username'] = username client_dict['ip_address'] = ip_address client_dict['user_agent'] = user_agent else: # Other modes initialize the attributes that are used for the actual lockouts client_dict = get_client_parameters(username, ip_address, user_agent) # Path info is always included as last component in the client string for traceability purposes if path_info and isinstance(path_info, (tuple, list)): path_info = path_info[0] client_dict['path_info'] = path_info # Template the internal dictionary representation into a readable and concatenated key: "value" format template = ', '.join( f'{key}: "{value}"' for key, value in client_dict.items() ) # Wrap the internal dict into a single {key: "value"} bracing in the output # which requires double braces when done with the Python string templating system # i.e. {{key: "value"}} becomes {key: "value"} when run through a .format() call template = '{{' + template + '}}' return template.format(client_dict) def get_query_str(query: Type[QueryDict], max_length: int = 1024) -> str: """ Turns a query dictionary into an easy-to-read list of key-value pairs. If a field is called either ``'password'`` or ``settings.AXES_PASSWORD_FORM_FIELD`` it will be excluded. The length of the output is limited to max_length to avoid a DoS attack via excessively large payloads. """ query_dict = query.copy() query_dict.pop('password', None) query_dict.pop(settings.AXES_PASSWORD_FORM_FIELD, None) query_str = '\n'.join( f'{key}={value}' for key, value in query_dict.items() ) return query_str[:max_length] def get_failure_limit(request, credentials) -> int: if callable(settings.AXES_FAILURE_LIMIT): return settings.AXES_FAILURE_LIMIT(request, credentials) if isinstance(settings.AXES_FAILURE_LIMIT, str): return import_string(settings.AXES_FAILURE_LIMIT)(request, credentials) if isinstance(settings.AXES_FAILURE_LIMIT, int): return settings.AXES_FAILURE_LIMIT raise TypeError('settings.AXES_FAILURE_LIMIT needs to be a callable or an integer') def get_lockout_message() -> str: if settings.AXES_COOLOFF_TIME: return settings.AXES_COOLOFF_MESSAGE return settings.AXES_PERMALOCK_MESSAGE def get_lockout_response(request, credentials: dict = None) -> HttpResponse: status = 403 context = { 'failure_limit': get_failure_limit(request, credentials), 'username': get_client_username(request, credentials) or '' } cool_off = get_cool_off() if cool_off: context.update({ 'cooloff_time': get_cool_off_iso8601(cool_off), # differing old name is kept for backwards compatibility }) if request.is_ajax(): return JsonResponse( context, status=status, ) if settings.AXES_LOCKOUT_TEMPLATE: return render( request, settings.AXES_LOCKOUT_TEMPLATE, context, status=status, ) if settings.AXES_LOCKOUT_URL: return HttpResponseRedirect( settings.AXES_LOCKOUT_URL, ) return HttpResponse( get_lockout_message(), status=status, ) def is_ip_address_in_whitelist(ip_address: str) -> bool: if not settings.AXES_IP_WHITELIST: return False return ip_address in settings.AXES_IP_WHITELIST def is_ip_address_in_blacklist(ip_address: str) -> bool: if not settings.AXES_IP_BLACKLIST: return False return ip_address in settings.AXES_IP_BLACKLIST def is_client_ip_address_whitelisted(request): """ Check if the given request refers to a whitelisted IP. """ if settings.AXES_NEVER_LOCKOUT_WHITELIST and is_ip_address_in_whitelist(request.axes_ip_address): return True if settings.AXES_ONLY_WHITELIST and is_ip_address_in_whitelist(request.axes_ip_address): return True return False def is_client_ip_address_blacklisted(request) -> bool: """ Check if the given request refers to a blacklisted IP. """ if is_ip_address_in_blacklist(request.axes_ip_address): return True if settings.AXES_ONLY_WHITELIST and not is_ip_address_in_whitelist(request.axes_ip_address): return True return False def is_client_method_whitelisted(request) -> bool: """ Check if the given request uses a whitelisted method. """ if settings.AXES_NEVER_LOCKOUT_GET and request.method == 'GET': return True return False def get_client_cache_key(request_or_attempt: Union[HttpRequest, Any], credentials: dict = None) -> str: """ Build cache key name from request or AccessAttempt object. :param request_or_attempt: HttpRequest or AccessAttempt object :param credentials: credentials containing user information :return cache_key: Hash key that is usable for Django cache backends """ if isinstance(request_or_attempt, HttpRequest): username = get_client_username(request_or_attempt, credentials) ip_address = get_client_ip_address(request_or_attempt) user_agent = get_client_user_agent(request_or_attempt) else: username = request_or_attempt.username ip_address = request_or_attempt.ip_address user_agent = request_or_attempt.user_agent filter_kwargs = get_client_parameters(username, ip_address, user_agent) cache_key_components = ''.join(value for value in filter_kwargs.values() if value) cache_key_digest = md5(cache_key_components.encode()).hexdigest() cache_key = f'axes-{cache_key_digest}' return cache_key def toggleable(func) -> Callable: """ Decorator that toggles function execution based on settings. If the ``settings.AXES_ENABLED`` flag is set to ``False`` the decorated function never runs and a None is returned. This decorator is only suitable for functions that do not require return values to be passed back to callers. """ def inner(*args, **kwargs): # pylint: disable=inconsistent-return-statements if settings.AXES_ENABLED: return func(*args, **kwargs) return inner django-axes-5.0.7/requirements.txt0000644000175000017500000000021613500726664016177 0ustar jamesjames-e . coverage==4.5.3 mypy==0.701 prospector==1.1.6.2 pytest==4.6.2 pytest-cov==2.7.1 pytest-django==3.5.0 sphinx_rtd_theme==0.4.3 tox==3.12.1 django-axes-5.0.7/manage.py0000644000175000017500000000037613500726664014524 0ustar jamesjames#!/usr/bin/env python import os import sys if __name__ == '__main__': os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'axes.tests.settings') from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) django-axes-5.0.7/setup.py0000644000175000017500000000377413500726664014441 0ustar jamesjames#!/usr/bin/env python from setuptools import setup, find_packages from axes import get_version setup( name='django-axes', version=get_version(), description='Keep track of failed login attempts in Django-powered sites.', long_description='\n'.join([ open('README.rst', encoding='utf-8').read(), open('CHANGES.rst', encoding='utf-8').read(), ]), keywords='authentication django pci security', author=', '.join([ 'Josh VanderLinden', 'Philip Neustrom', 'Michael Blume', 'Alex Clark', 'Camilo Nova', 'Aleksi Hakli', ]), author_email='security@jazzband.co', maintainer='Jazzband', maintainer_email='security@jazzband.co', url='https://github.com/jazzband/django-axes', project_urls={ 'Documentation': 'https://django-axes.readthedocs.io/', 'Source': 'https://github.com/jazzband/django-axes', 'Tracker': 'https://github.com/jazzband/django-axes/issues', }, license='MIT', package_dir={'axes': 'axes'}, python_requires='~=3.6', install_requires=[ 'django>=1.11', 'django-appconf>=1.0.3', 'django-ipware>=2.0.2', ], include_package_data=True, packages=find_packages(), classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Framework :: Django', 'Framework :: Django :: 1.11', 'Framework :: Django :: 2.1', 'Framework :: Django :: 2.2', 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Topic :: Internet :: Log Analysis', 'Topic :: Security', 'Topic :: System :: Logging', ], zip_safe=False, )