django-axes-5.39.0/0000755000175000017500000000000014277437635013012 5ustar jamesjamesdjango-axes-5.39.0/.github/0000755000175000017500000000000014277437635014352 5ustar jamesjamesdjango-axes-5.39.0/.github/dependabot.yml0000644000175000017500000000043514277437635017204 0ustar jamesjamesversion: 2 updates: - package-ecosystem: "pip" directory: "/" schedule: interval: "daily" time: "12:00" open-pull-requests-limit: 10 - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" time: "12:00" open-pull-requests-limit: 10 django-axes-5.39.0/.github/workflows/0000755000175000017500000000000014277437635016407 5ustar jamesjamesdjango-axes-5.39.0/.github/workflows/codeql.yml0000644000175000017500000000262114277437635020402 0ustar jamesjamesname: "Code Scanning - Action" on: pull_request: jobs: CodeQL-Build: # CodeQL runs on ubuntu-latest, windows-latest, and macos-latest runs-on: ubuntu-latest permissions: # required for all workflows security-events: write steps: - name: Checkout repository uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v2 # Override language selection by uncommenting this and choosing your languages # with: # languages: go, javascript, csharp, python, cpp, java # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below). - name: Autobuild uses: github/codeql-action/autobuild@v2 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # âœī¸ If the Autobuild fails above, remove it and uncomment the following # three lines and modify them (or add more) to build your code if your # project uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v2 django-axes-5.39.0/.github/workflows/release.yml0000644000175000017500000000200114277437635020543 0ustar jamesjamesname: Release on: push: tags: - '*' permissions: contents: read jobs: build: if: github.repository == 'jazzband/django-axes' runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v4 with: python-version: 3.8 - name: Install dependencies run: | python -m pip install -U pip python -m pip install -U setuptools twine wheel - name: Build package run: | python setup.py --version python setup.py sdist --format=gztar bdist_wheel twine check dist/* - name: Upload packages to Jazzband if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@master with: user: jazzband password: ${{ secrets.JAZZBAND_RELEASE_KEY }} repository_url: https://jazzband.co/projects/django-axes/upload django-axes-5.39.0/.github/workflows/test.yml0000644000175000017500000000440414277437635020113 0ustar jamesjamesname: Test on: [push, pull_request] permissions: contents: read jobs: build: name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) runs-on: ubuntu-latest strategy: fail-fast: false max-parallel: 5 matrix: python-version: ['3.7', '3.8', '3.9', '3.10', 'pypy-3.8'] django-version: ['3.2', '4.0', '4.1'] include: # Tox configuration for QA environment - python-version: '3.10' django-version: 'qa' # Django main - python-version: '3.8' django-version: 'main' experimental: true - python-version: '3.9' django-version: 'main' experimental: true - python-version: '3.10' django-version: 'main' experimental: true - python-version: 'pypy-3.8' django-version: '4.1' experimental: true - python-version: 'pypy-3.8' django-version: 'main' experimental: true exclude: # Exclude Python 3.7 for Django 4.0 and Django main - python-version: '3.7' django-version: '4.0' - python-version: '3.7' django-version: '4.1' - python-version: '3.7' django-version: 'main' steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Get pip cache dir id: pip-cache run: | echo "::set-output name=dir::$(pip cache dir)" - name: Cache uses: actions/cache@v3 with: path: ${{ steps.pip-cache.outputs.dir }} key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }} restore-keys: | ${{ matrix.python-version }}-v1- - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install --upgrade tox tox-gh-actions - name: Tox tests run: | tox -v env: DJANGO: ${{ matrix.django-version }} - name: Upload coverage uses: codecov/codecov-action@v3 with: name: Python ${{ matrix.python-version }} django-axes-5.39.0/.gitignore0000644000175000017500000000027114277437635015002 0ustar jamesjames*.egg-info *.pyc *.swp .coverage coverage.xml .DS_Store .idea .mypy_cache/ .project .pydevproject .python-version .tox build/ dist/ docs/_build test.db .eggs pip-wheel-metadata .vscode/django-axes-5.39.0/.pre-commit-config.yaml0000644000175000017500000000001214277437635017264 0ustar jamesjamesrepos: [] django-axes-5.39.0/.prospector.yaml0000644000175000017500000000013514277437635016153 0ustar jamesjamesignore-paths: - docs - axes/migrations pycodestyle: options: max-line-length: 142 django-axes-5.39.0/CHANGES.rst0000644000175000017500000007250614277437635014626 0ustar jamesjames Changes ======= 5.39.0 (2022-08-18) ------------------- - Utilize new backend class in tests to fix false negative system check warnings. [simonkern] 5.38.0 (2022-08-16) ------------------- - Adjust changelog so release notes are correctly visible on PyPy and released package. [aleksihakli] 5.37.0 (2022-08-16) ------------------- - Add Django 4.1 support. PyPy 3.8 has a known issue with Django 4.1 and is exempted. [hramezani] 5.36.0 (2022-07-17) ------------------- - Add ``AxesStandaloneBackend`` without ``ModelBackend`` dependencies. [jcgiuffrida] 5.35.0 (2022-06-01) ------------------- - Add Arabic translations. [YDA93] 5.34.0 (2022-05-28) ------------------- - Improve German translations. [GitRon] 5.33.0 (2022-05-16) ------------------- - Migrate MD5 cache key digests to SHA256. [aleksihakli] - Improve and streamline startup logging. [ShaheedHaque] - Improve module typing. [hramezani] - Add support for float or partial hours for ``AXES_COOLOFF_TIME``. [hramezani] 5.32.0 (2022-04-08) ------------------- - Add support for persistent failure logging where failed login attempts are persisted in the database until a specific threshold is reached. [p1-gdd] - Add support for not resetting login times when users try to login during the lockout cooloff period. [antoine-42] 5.31.0 (2022-01-08) ------------------- - Adjust version specifiers for newer Python and other package versions. Set package minimum Python version to 3.7. Relax ``django-ipware`` version requirements to allow newer versions. [aleksihakli] 5.30.0 (2022-01-08) ------------------- - Fix package build error in 5.29.0 to allow publishing. [aleksihakli] 5.29.0 (2022-01-08) ------------------- - Drop Python 3.6 support. [aleksihakli] 5.28.0 (2021-12-14) ------------------- - Drop Django < 3.2 support. [hramezani] - Add Django 4.0 to test matrix. [hramezani] 5.27.0 (2021-11-04) ------------------- - Fix ``pkg_resources`` missing for package version resolution on runtime due to ``setuptools`` not being a runtime dependency. [asherf] - Add Python 3.10 and Django 3.2 support. [hramezani] 5.26.0 (2021-10-11) ------------------- - Fix ``AXES_USERNAME_CALLABLE`` not receiving ``credentials`` attribute in Axes middleware lockout response when user is locked out. [rootart] 5.25.0 (2021-09-19) ------------------- - Fix duplicated AccessAttempts with updated database model ``unique_together`` constraints and data and schema migration. [PetrDlouhy] 5.24.0 (2021-09-09) ------------------- - Use atomic transaction for updating AccessAttempts in database handler. [okapies] 5.23.0 (2021-09-02) ------------------- - Pass ``request`` as argument to ``AXES_CLIENT_STR_CALLABLE``. [sarahboyce] 5.22.0 (2021-08-31) ------------------- - Improve ``failures_since_start`` handling by moving the counter incrementation from non-atomic Python code call to atomic database function. [okapies] - Add publicly available ``request.axes_failures_since_start`` attribute. [okapies] 5.21.0 (2021-08-19) ------------------- - Add configurable lockout HTTP status code responses with the new ``AXES_HTTP_RESPONSE_CODE`` setting. [phil-bell] 5.20.0 (2021-06-29) ------------------- - Improve race condition handling in e.g. multi-process environments by using ``get_or_create`` for access attempt fetching and updates. [uli-klank] 5.19.0 (2021-06-16) ------------------- - Add Polish locale. [Quadric] 5.18.0 (2021-06-09) ------------------- - Fix ``default_auto_field`` warning. [zkanda] 5.17.0 (2021-06-05) ------------------- - Fix ``default_app_config`` deprecation. Django 3.2 automatically detects ``AppConfig`` and therefore this setting is no longer required. [nikolaik] 5.16.0 (2021-05-19) ------------------- - Add ``AXES_CLIENT_STR_CALLABLE`` setting. [smtydn] 5.15.0 (2021-05-03) ------------------- - Add option to cleanse sensitive GET and POST params in database handler with the ``AXES_SENSITIVE_PARAMETERS`` setting. [mcoconnor] 5.14.0 (2021-04-06) ------------------- - Improve message formatting for lockout message and translations. [ashokdelphia] - Remove support for Django 3.0. [hramezani] - Add support for Django 3.2. [hramezani] 5.13.1 (2021-02-22) ------------------- - Default ``AXES_VERBOSE`` to ``AXES_ENABLED`` configuration setting, disabling verbose startup logging when Axes itself is disabled. [christianbundy] - Update documentation. [KStenK] 5.13.0 (2021-02-15) ------------------- - Add support for resetting attempts with cache backend. [nattyg93] 5.12.0 (2021-01-07) ------------------- - Clean up test structure and migrate tests outside the main package for a smaller wheel distributions. [aleksihakli] - Move configuration to pyproject.toml for cleaner layout. [aleksihakli] - Clean up test settings override configuration. [hramezani] 5.11.1 (2021-01-06) ------------------- - Fix cache entry creations for None username. [cabarnes] 5.11.0 (2021-01-05) ------------------- - Add lockout view CORS support with ``AXES_ALLOWED_CORS_ORIGINS`` configuration flag. [vladox] - Add missing ``@wraps`` decorator to ``axes.decorators.axes_dispatch``. [aleksihakli] 5.10.1 (2021-01-04) ------------------- - Add ``DEFAULT_AUTO_FIELD`` to test settings. [hramezani] - Fix documentation language. [danielquinn] - Fix Python package version specifiers and remove redundant imports. [aleksihakli] 5.10.0 (2020-12-18) ------------------- - Deprecate stock DRF support from 5.8.0, require users to set it up per project. Check the documentation for more information. [aleksihakli] 5.9.1 (2020-12-02) ------------------ - Move tests to GitHub Actions [jezdez] - Fix running Axes code in middleware when ``AXES_ENABLED`` is ``False``. [ashokdelphia] 5.9.0 (2020-11-05) ------------------ - Add Python 3.9 support. [hramezani] - Prevent ``AccessAttempt`` creation with database handler when username is not set and ``AXES_ONLY_USER_FAILURES`` setting is not set. [hramezani] 5.8.0 (2020-10-16) ------------------ - Improve Django REST Framework (DRF) integration. [Anatoly] 5.7.1 (2020-09-27) ------------------ - Adjust settings import and handling chain for cleaner module import and invocation order. [aleksihakli] - Adjust the use of ``AXES_ENABLED`` flag so that imports are always done the same way and initial log is written regardless of the setting and it only affects code that is decorated or wrapped with ``toggleable``. [alekshakli] 5.7.0 (2020-09-26) ------------------ - Deprecate ``AXES_LOGGER`` Axes setting and move to ``__name__`` based logging and fully qualified Python module name log identifiers. [aleksihakli] 5.6.2 (2020-09-20) ------------------ - Fix regression in ``axes_reset_user`` management command. [aleksihakli] 5.6.1 (2020-09-17) ------------------ - Improve test dependency management and upgrade black code formatter. [smithdc1] 5.6.0 (2020-09-12) ------------------ - Add proper development ``subTest`` support via ``pytest-subtests`` package. [smithdc1] - Deprecate ``django-appconf`` and use plain settings for Axes. [aleksihakli] 5.5.2 (2020-09-11) ------------------ - Update deprecating use of the ``request.is_ajax`` method. [smithdc1] 5.5.1 (2020-09-10) ------------------ - Update deprecated uses of Django modules and members. [smithdc1] 5.5.0 (2020-08-21) ------------------ - Add support for locking requests based on username OR IP address with inclusive or using the ``LOCK_OUT_BY_USER_OR_IP`` flag. [PetrDlouhy] - Deprecate Signal ``providing_args`` for Django 3.1 support. [coredumperror] 5.4.3 (2020-08-06) ------------------ - Add Django 3.1 support. [hramezani] 5.4.2 (2020-07-28) ------------------ - Add ABC or abstract base class implementation for handlers. [jorlugaqui] 5.4.1 (2020-07-03) ------------------ - Fix code styling for linters. [aleksihakli] 5.4.0 (2020-07-03) ------------------ - Propagate username to lockout view in URL parameters. [PetrDlouhy] - Update CAPTCHA examples. [PetrDlouhy] - Upgrade django-ipware to version 3. [hramezani,mnislam01] 5.3.5 (2020-07-02) ------------------ - Restrict ipware version for version compatibility. [aleksihakli] 5.3.4 (2020-06-09) ------------------ - Deprecate Django 1.11 LTS support. [aleksihakli] 5.3.3 (2020-05-22) ------------------ - Fix ``AXES_ONLY_ADMIN_SITE`` functionality when no default admin site is defined in the URL configuration. [igor-shevchenko] 5.3.2 (2020-05-15) ------------------ - Fix AppConf settings prefix for Fargate. [marksweb] 5.3.1 (2020-03-23) ------------------ - Fix null byte ValueError bug in ORM. [ddimmich] 5.3.0 (2020-03-10) ------------------ - Improve Django REST Framework compatibility. [I0x4dI] 5.2.2 (2020-01-08) ------------------ - Add missing proxy implementation for ``axes.handlers.proxy.AxesProxyHandler.get_failures``. [aleksihakli] 5.2.1 (2020-01-08) ------------------ - Add django-reversion compatibility notes. [mark-mishyn] - Add pluggable lockout responses and the ``AXES_LOCKOUT_CALLABLE`` configuration flag. [aleksihakli] 5.2.0 (2020-01-01) ------------------ - Add a test handler. [aidanlister] 5.1.0 (2019-12-29) ------------------ - Add pluggable user account whitelisting and the ``AXES_WHITELIST_CALLABLE`` configuration flag. [aleksihakli] 5.0.20 (2019-12-01) ------------------- - Fix django-allauth compatibility issue. [hramezani] - Improve tests for login attempt monitoring. [hramezani] - Add reverse proxy documentation. [ckcollab] - Update OAuth documentation examples. [aleksihakli] 5.0.19 (2019-11-06) ------------------- - Optimize access attempt fetching in database handler. [hramezani] - Optimize request data fetching in proxy handler. [hramezani] 5.0.18 (2019-10-17) ------------------- - Add ``cooloff_timedelta`` context variable to lockout responses. [jstockwin] 5.0.17 (2019-10-15) ------------------- - Safer string formatting for user input. [aleksihakli] 5.0.16 (2019-10-15) ------------------- - Fix string formatting bug in logging. [zerolab] 5.0.15 (2019-10-09) ------------------- - Add ``AXES_ENABLE_ADMIN`` flag. [flannelhead] 5.0.14 (2019-09-28) ------------------- - Docs, CI pipeline, and code formatting improvements [aleksihakli] 5.0.13 (2019-08-30) ------------------- - Python 3.8 and PyPy support. [aleksihakli] - Migrate to ``setuptools_scm`` and automatic versioning. [aleksihakli] 5.0.12 (2019-08-05) ------------------- - Support callables for ``AXES_COOLOFF_TIME`` setting. [DariaPlotnikova] 5.0.11 (2019-07-25) ------------------- - Fix typo in rST formatting that prevented 5.0.10 release to PyPI. [aleksihakli] 5.0.10 (2019-07-25) ------------------- - Refactor type checks for ``axes.helpers.get_client_cache_key`` for framework compatibility, fixes #471. [aleksihakli] 5.0.9 (2019-07-11) ------------------ - Add better handling for attempt and log resets by moving them into handlers which allows customization and more configurability. Unimplemented handlers raise ``NotImplementedError`` by default. [aleksihakli] - Add Python 3.8 dev version and PyPy to the Travis test matrix. [aleksihakli] 5.0.8 (2019-07-09) ------------------ - Add ``AXES_ONLY_ADMIN_SITE`` flag for only running Axes on admin site. [hramezani] - Add ``axes_reset_logs`` command for removing old AccessLog records. [tlebrize] - Allow ``AxesBackend`` subclasses to pass the ``axes.W003`` system check. [adamchainz] 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.39.0/CODE_OF_CONDUCT.md0000644000175000017500000000450714277437635015617 0ustar jamesjames# Code of Conduct As contributors and maintainers of the Jazzband projects, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. We are committed to making participation in the Jazzband a harassment-free experience for everyone, regardless of the level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. Examples of unacceptable behavior by participants include: - The use of sexualized language or imagery - Personal attacks - Trolling or insulting/derogatory comments - Public or private harassment - Publishing other's private information, such as physical or electronic addresses, without explicit permission - Other unethical or unprofessional conduct The Jazzband roadies have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. By adopting this Code of Conduct, the roadies commit themselves to fairly and consistently applying these principles to every aspect of managing the jazzband projects. Roadies who do not follow or enforce the Code of Conduct may be permanently removed from the Jazzband roadies. This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Roadies are obligated to maintain confidentiality with regard to the reporter of an incident. This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] [homepage]: https://contributor-covenant.org [version]: https://contributor-covenant.org/version/1/3/0/ django-axes-5.39.0/LICENSE0000644000175000017500000000224014277437635014015 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.39.0/README.rst0000644000175000017500000000653314277437635014510 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/pypi/pyversions/django-axes.svg :target: https://pypi.org/project/django-axes/ :alt: Supported Python versions .. image:: https://img.shields.io/pypi/djversions/django-axes.svg :target: https://pypi.org/project/django-axes/ :alt: Supported Django versions .. image:: https://img.shields.io/readthedocs/django-axes.svg :target: https://django-axes.readthedocs.io/ :alt: Documentation .. image:: https://github.com/jazzband/django-axes/workflows/Test/badge.svg :target: https://github.com/jazzband/django-axes/actions :alt: GitHub Actions .. image:: https://codecov.io/gh/jazzband/django-axes/branch/master/graph/badge.svg :target: https://codecov.io/gh/jazzband/django-axes :alt: Coverage Axes is a Django plugin for keeping track of suspicious login attempts for your Django based website and implementing simple brute-force attack blocking. 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). Functionality ------------- Axes records login attempts to your Django powered site and prevents attackers from attempting further logins to your 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 ------------- All contributions are welcome! It is best to separate proposed changes and PRs into small, distinct patches by type so that they can be merged faster into upstream and released quicker. One way to organize contributions would be to separate PRs for e.g. * bugfixes, * new features, * code and design improvements, * documentation improvements, or * tooling and CI improvements. Merging contributions requires passing the checks configured with the CI. This includes running tests and linters successfully on the currently officially supported Python and Django versions. The test automation is run automatically with GitHub Actions, but you can run it locally with the ``tox`` command before pushing commits. Please note that this is a `Jazzband `_ project. By contributing you agree to abide by the `Contributor Code of Conduct `_ and follow the `guidelines `_. django-axes-5.39.0/axes/0000755000175000017500000000000014277437635013752 5ustar jamesjamesdjango-axes-5.39.0/axes/__init__.py0000644000175000017500000000014314277437635016061 0ustar jamesjamesfrom pkg_resources import get_distribution __version__ = get_distribution("django-axes").version django-axes-5.39.0/axes/admin.py0000644000175000017500000000563614277437635015426 0ustar jamesjamesfrom django.contrib import admin from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ from axes.conf import settings from axes.models import AccessAttempt, AccessLog, AccessFailureLog 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: HttpRequest) -> bool: return False 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: HttpRequest) -> bool: return False class AccessFailureLogAdmin(admin.ModelAdmin): list_display = ( "attempt_time", "ip_address", "username", "user_agent", "path_info", "locked_out", ) list_filter = ["attempt_time", "locked_out", "path_info"] search_fields = ["ip_address", "user_agent", "username", "path_info"] date_hierarchy = "attempt_time" fieldsets = ( (None, {"fields": ("path_info",)}), (_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}), ) readonly_fields = [ "user_agent", "ip_address", "username", "http_accept", "path_info", "attempt_time", "locked_out", ] def has_add_permission(self, request: HttpRequest) -> bool: return False if settings.AXES_ENABLE_ADMIN: admin.site.register(AccessAttempt, AccessAttemptAdmin) admin.site.register(AccessLog, AccessLogAdmin) admin.site.register(AccessFailureLog, AccessFailureLogAdmin) django-axes-5.39.0/axes/apps.py0000644000175000017500000000275614277437635015301 0ustar jamesjamesfrom logging import getLogger from django import apps from pkg_resources import get_distribution log = getLogger(__name__) class AppConfig(apps.AppConfig): default_auto_field = "django.db.models.AutoField" name = "axes" 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 cls.initialized: return cls.initialized = True # Only import settings, checks, and signals one time after Django has been initialized from axes.conf import settings # noqa from axes import checks, signals # noqa # Skip startup log messages if Axes is not set to verbose if settings.AXES_VERBOSE: if settings.AXES_ONLY_USER_FAILURES: mode = "blocking by username only" elif settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP: mode = "blocking by combination of username and IP" elif settings.AXES_LOCK_OUT_BY_USER_OR_IP: mode = "blocking by username or IP" else: mode = "blocking by IP only" log.info( "AXES: BEGIN version %s, %s", get_distribution("django-axes").version, mode, ) def ready(self): self.initialize() django-axes-5.39.0/axes/attempts.py0000644000175000017500000000617214277437635016173 0ustar jamesjamesfrom logging import getLogger from typing import List, Optional from django.db.models import QuerySet from django.http import HttpRequest from django.utils.timezone import datetime, now from axes.conf import settings from axes.helpers import get_client_username, get_client_parameters, get_cool_off from axes.models import AccessAttempt log = getLogger(__name__) def get_cool_off_threshold(attempt_time: Optional[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: HttpRequest, credentials: Optional[dict] = None ) -> List[QuerySet]: """ Return a list querysets of AccessAttempts that match the given request and credentials. """ username = get_client_username(request, credentials) filter_kwargs_list = get_client_parameters( username, request.axes_ip_address, request.axes_user_agent ) attempts_list = [ AccessAttempt.objects.filter(**filter_kwargs) for filter_kwargs in filter_kwargs_list ] return attempts_list def get_user_attempts( request: HttpRequest, credentials: Optional[dict] = None ) -> List[QuerySet]: """ Get list of querysets with valid user attempts that match the given request and credentials. """ attempts_list = filter_user_attempts(request, credentials) if settings.AXES_COOLOFF_TIME is None: log.debug( "AXES: Getting all access attempts from database because no AXES_COOLOFF_TIME is configured" ) return attempts_list threshold = get_cool_off_threshold(request.axes_attempt_time) log.debug("AXES: Getting access attempts that are newer than %s", threshold) return [attempts.filter(attempt_time__gte=threshold) for attempts in attempts_list] def clean_expired_user_attempts(attempt_time: Optional[datetime] = None) -> int: """ Clean expired user attempts from the database. """ if settings.AXES_COOLOFF_TIME is None: log.debug( "AXES: Skipping clean for expired access attempts because no AXES_COOLOFF_TIME is configured" ) return 0 threshold = get_cool_off_threshold(attempt_time) count, _ = AccessAttempt.objects.filter(attempt_time__lt=threshold).delete() log.info( "AXES: Cleaned up %s expired access attempts from database that were older than %s", count, threshold, ) return count def reset_user_attempts( request: HttpRequest, credentials: Optional[dict] = None ) -> int: """ Reset all user attempts that match the given request and credentials. """ attempts_list = filter_user_attempts(request, credentials) count = 0 for attempts in attempts_list: _count, _ = attempts.delete() count += _count log.info("AXES: Reset %s access attempts from database.", count) return count django-axes-5.39.0/axes/backends.py0000644000175000017500000000741714277437635016107 0ustar jamesjamesfrom typing import Optional from django.conf import settings from django.contrib.auth.backends import BaseBackend, ModelBackend from django.http import HttpRequest from axes.exceptions import ( AxesBackendPermissionDenied, AxesBackendRequestParameterRequired, ) from axes.handlers.proxy import AxesProxyHandler from axes.helpers import get_credentials, get_lockout_message, toggleable class AxesStandaloneBackend(BaseBackend): """ 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. It also does not run any permissions checks at all. Authentication is handled by the following backends that are configured in ``AUTHENTICATION_BACKENDS``. """ @toggleable def authenticate( self, request: HttpRequest, username: Optional[str] = None, password: Optional[str] = None, **kwargs: dict, ): """ Checks user lockout status and raises an exception if user is not allowed to log in. 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 # This flag can be used later to check if it was Axes that denied the login attempt. if not settings.AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT: request.axes_locked_out = True # Raise an error that stops the authentication flows at django.contrib.auth.authenticate. # This error stops bubbling up at the authenticate call which catches backend PermissionDenied errors. # After this error is caught by authenticate it emits a signal indicating user login failed, # 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" ) class AxesBackend(AxesStandaloneBackend, ModelBackend): """ Axes authentication backend that also inherits from ModelBackend, and thus also performs other functions of ModelBackend such as permissions checks. Use this class as the first item of ``AUTHENTICATION_BACKENDS`` to prevent locked out users from being logged in by the Django authentication flow. .. note:: This backend does not log your user in. It monitors login attempts. Authentication is handled by the following backends that are configured in ``AUTHENTICATION_BACKENDS``. """ django-axes-5.39.0/axes/checks.py0000644000175000017500000001073714277437635015574 0ustar jamesjamesfrom django.core.checks import ( # pylint: disable=redefined-builtin Tags, Warning, register, ) from django.utils.module_loading import import_string from axes.backends import AxesStandaloneBackend 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.AxesStandaloneBackend' or a subclass 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 AxesStandaloneBackend 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 = [] found = False for name in settings.AUTHENTICATION_BACKENDS: try: backend = import_string(name) except ModuleNotFoundError as e: raise ModuleNotFoundError( "Can not find module path defined in settings.AUTHENTICATION_BACKENDS" ) from e except ImportError as e: raise ImportError( "Can not import backend class defined in settings.AUTHENTICATION_BACKENDS" ) from e if issubclass(backend, AxesStandaloneBackend): found = True break if not found: 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", "AXES_LOGGER", ] 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.39.0/axes/conf.py0000644000175000017500000001341214277437635015252 0ustar jamesjamesfrom django.conf import settings from django.utils.translation import gettext_lazy as _ # disable plugin when set to False settings.AXES_ENABLED = getattr(settings, "AXES_ENABLED", True) # see if the user has overridden the failure limit settings.AXES_FAILURE_LIMIT = getattr(settings, "AXES_FAILURE_LIMIT", 3) # see if the user has set axes to lock out logins after failure limit settings.AXES_LOCK_OUT_AT_FAILURE = getattr(settings, "AXES_LOCK_OUT_AT_FAILURE", True) # lock out with the combination of username and IP address settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP = getattr( settings, "AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP", False ) # lock out with the username or IP address settings.AXES_LOCK_OUT_BY_USER_OR_IP = getattr( settings, "AXES_LOCK_OUT_BY_USER_OR_IP", False ) # lock out with username and never the IP or user agent settings.AXES_ONLY_USER_FAILURES = getattr(settings, "AXES_ONLY_USER_FAILURES", False) # lock out just for admin site settings.AXES_ONLY_ADMIN_SITE = getattr(settings, "AXES_ONLY_ADMIN_SITE", False) # show Axes logs in admin settings.AXES_ENABLE_ADMIN = getattr(settings, "AXES_ENABLE_ADMIN", True) # lock out with the user agent, has no effect when ONLY_USER_FAILURES is set settings.AXES_USE_USER_AGENT = getattr(settings, "AXES_USE_USER_AGENT", False) # use a specific username field to retrieve from login POST data settings.AXES_USERNAME_FORM_FIELD = getattr( settings, "AXES_USERNAME_FORM_FIELD", "username" ) # use a specific password field to retrieve from login POST data settings.AXES_PASSWORD_FORM_FIELD = getattr( settings, "AXES_PASSWORD_FORM_FIELD", "password" ) # noqa # use a provided callable to transform the POSTed username into the one used in credentials settings.AXES_USERNAME_CALLABLE = getattr(settings, "AXES_USERNAME_CALLABLE", None) # determine if given user should be always allowed to attempt authentication settings.AXES_WHITELIST_CALLABLE = getattr(settings, "AXES_WHITELIST_CALLABLE", None) # return custom lockout response if configured settings.AXES_LOCKOUT_CALLABLE = getattr(settings, "AXES_LOCKOUT_CALLABLE", None) # reset the number of failed attempts after one successful attempt settings.AXES_RESET_ON_SUCCESS = getattr(settings, "AXES_RESET_ON_SUCCESS", False) settings.AXES_DISABLE_ACCESS_LOG = getattr(settings, "AXES_DISABLE_ACCESS_LOG", False) settings.AXES_ENABLE_ACCESS_FAILURE_LOG = getattr( settings, "AXES_ENABLE_ACCESS_FAILURE_LOG", False ) settings.AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT = getattr( settings, "AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT", 1000 ) settings.AXES_HANDLER = getattr( settings, "AXES_HANDLER", "axes.handlers.database.AxesDatabaseHandler" ) settings.AXES_LOCKOUT_TEMPLATE = getattr(settings, "AXES_LOCKOUT_TEMPLATE", None) settings.AXES_LOCKOUT_URL = getattr(settings, "AXES_LOCKOUT_URL", None) settings.AXES_COOLOFF_TIME = getattr(settings, "AXES_COOLOFF_TIME", None) settings.AXES_VERBOSE = getattr(settings, "AXES_VERBOSE", settings.AXES_ENABLED) # whitelist and blacklist settings.AXES_NEVER_LOCKOUT_WHITELIST = getattr( settings, "AXES_NEVER_LOCKOUT_WHITELIST", False ) settings.AXES_NEVER_LOCKOUT_GET = getattr(settings, "AXES_NEVER_LOCKOUT_GET", False) settings.AXES_ONLY_WHITELIST = getattr(settings, "AXES_ONLY_WHITELIST", False) settings.AXES_IP_WHITELIST = getattr(settings, "AXES_IP_WHITELIST", None) settings.AXES_IP_BLACKLIST = getattr(settings, "AXES_IP_BLACKLIST", None) # message to show when locked out and have cooloff enabled settings.AXES_COOLOFF_MESSAGE = getattr( settings, "AXES_COOLOFF_MESSAGE", _("Account locked: too many login attempts. Please try again later."), ) # message to show when locked out and have cooloff disabled settings.AXES_PERMALOCK_MESSAGE = getattr( settings, "AXES_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 settings.AXES_PROXY_ORDER = getattr(settings, "AXES_PROXY_ORDER", "left-most") # if your deployment is using reverse proxies, set this value to the number of proxies in front of Django settings.AXES_PROXY_COUNT = getattr(settings, "AXES_PROXY_COUNT", None) # if your deployment is using reverse proxies, set to your trusted proxy IP addresses prefixes if needed settings.AXES_PROXY_TRUSTED_IPS = getattr(settings, "AXES_PROXY_TRUSTED_IPS", None) # set to the names of request.META attributes that should be checked for the IP address of the client # if your deployment is using reverse proxies, ensure that the header attributes are securely set by the proxy # ensure that the client can not spoof the headers by setting them and sending them through the proxy settings.AXES_META_PRECEDENCE_ORDER = getattr( settings, "AXES_META_PRECEDENCE_ORDER", getattr(settings, "IPWARE_META_PRECEDENCE_ORDER", ("REMOTE_ADDR",)), ) # set CORS allowed origins when calling authentication over ajax settings.AXES_ALLOWED_CORS_ORIGINS = getattr(settings, "AXES_ALLOWED_CORS_ORIGINS", "*") # set the list of sensitive parameters to cleanse from get/post data before logging settings.AXES_SENSITIVE_PARAMETERS = getattr( settings, "AXES_SENSITIVE_PARAMETERS", [], ) # set the callable for the readable string that can be used in # e.g. logging to distinguish client requests settings.AXES_CLIENT_STR_CALLABLE = getattr(settings, "AXES_CLIENT_STR_CALLABLE", None) # set the HTTP response code given by too many requests settings.AXES_HTTP_RESPONSE_CODE = getattr(settings, "AXES_HTTP_RESPONSE_CODE", 403) # If True, a failed login attempt during lockout will reset the cool off period settings.AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT = getattr( settings, "AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT", True ) django-axes-5.39.0/axes/decorators.py0000644000175000017500000000116414277437635016473 0ustar jamesjamesfrom functools import wraps from axes.handlers.proxy import AxesProxyHandler from axes.helpers import get_lockout_response def axes_dispatch(func): @wraps(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.39.0/axes/exceptions.py0000644000175000017500000000057414277437635016513 0ustar jamesjamesfrom django.core.exceptions import PermissionDenied class AxesBackendPermissionDenied(PermissionDenied): """ Raised by authentication backend on locked out requests to stop the Django authentication flow. """ class AxesBackendRequestParameterRequired(ValueError): """ Raised by authentication backend on invalid or missing request parameter value. """ django-axes-5.39.0/axes/handlers/0000755000175000017500000000000014277437635015552 5ustar jamesjamesdjango-axes-5.39.0/axes/handlers/__init__.py0000644000175000017500000000000014277437635017651 0ustar jamesjamesdjango-axes-5.39.0/axes/handlers/base.py0000644000175000017500000001725414277437635017047 0ustar jamesjamesimport re from abc import ABC, abstractmethod from typing import Optional from django.urls import reverse from django.urls.exceptions import NoReverseMatch from 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, is_user_attempt_whitelisted, ) class AbstractAxesHandler(ABC): """ Contract that all handlers need to follow """ @abstractmethod def user_login_failed(self, sender, credentials: dict, request=None, **kwargs): """ Handles the Django ``django.contrib.auth.signals.user_login_failed`` authentication signal. """ raise NotImplementedError("user_login_failed should be implemented") @abstractmethod def user_logged_in(self, sender, request, user, **kwargs): """ Handles the Django ``django.contrib.auth.signals.user_logged_in`` authentication signal. """ raise NotImplementedError("user_logged_in should be implemented") @abstractmethod def user_logged_out(self, sender, request, user, **kwargs): """ Handles the Django ``django.contrib.auth.signals.user_logged_out`` authentication signal. """ raise NotImplementedError("user_logged_out should be implemented") @abstractmethod def get_failures(self, request, credentials: Optional[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("get_failures should be implemented") class AxesBaseHandler: # 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'``. Make sure that new the handler is compliant with AbstractAxesHandler and make sure it extends from this mixin. Refer to `AxesHandler` for an example. 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: Optional[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_admin_site(request): return True 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 is_blacklisted(self, request, credentials: Optional[dict] = None) -> bool: """ 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: Optional[dict] = None) -> bool: """ Checks if the request or given credentials are whitelisted for access. """ if is_user_attempt_whitelisted(request, credentials): return True 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: Optional[dict] = None) -> bool: """ Checks if the request or given credentials are locked. """ if settings.AXES_LOCK_OUT_AT_FAILURE: # get_failures will have to be implemented by each specialized handler return self.get_failures( # type: ignore request, credentials ) >= get_failure_limit(request, credentials) return False def is_admin_site(self, request) -> bool: """ Checks if the request is for admin site. """ if settings.AXES_ONLY_ADMIN_SITE and hasattr(request, "path"): try: admin_url = reverse("admin:index") except NoReverseMatch: return True return not re.match(f"^{admin_url}", request.path) return False def reset_attempts( self, *, ip_address: Optional[str] = None, username: Optional[str] = None, ip_or_username: bool = False, ) -> int: """ Resets access attempts that match the given IP address or username. This method makes more sense for the DB backend, but as it is used by the ProxyHandler (via inherent), it needs to be defined here, so we get compliant with all proxy methods. Please overwrite it on each specialized handler as needed. """ return 0 def reset_logs(self, *, age_days: Optional[int] = None) -> int: """ Resets access logs that are older than given number of days. This method makes more sense for the DB backend, but as it is used by the ProxyHandler (via inherent), it needs to be defined here, so we get compliant with all proxy methods. Please overwrite it on each specialized handler as needed. """ return 0 def reset_failure_logs(self, *, age_days: Optional[int] = None) -> int: """ Resets access failure logs that are older than given number of days. This method makes more sense for the DB backend, but as it is used by the ProxyHandler (via inherent), it needs to be defined here, so we get compliant with all proxy methods. Please overwrite it on each specialized handler as needed. """ return 0 def remove_out_of_limit_failure_logs( self, *, username: str, limit: Optional[int] = None ) -> int: """Remove access failure logs that are over AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT for user username. This method makes more sense for the DB backend, but as it is used by the ProxyHandler (via inherent), it needs to be defined here, so we get compliant with all proxy methods. Please overwrite it on each specialized handler as needed. """ return 0 class AxesHandler(AbstractAxesHandler, AxesBaseHandler): """ Signal bare handler implementation without any storage backend. """ def user_login_failed(self, sender, credentials: dict, request=None, **kwargs): pass def user_logged_in(self, sender, request, user, **kwargs): pass def user_logged_out(self, sender, request, user, **kwargs): pass def get_failures(self, request, credentials: Optional[dict] = None) -> int: return 0 django-axes-5.39.0/axes/handlers/cache.py0000644000175000017500000001421214277437635017167 0ustar jamesjamesfrom logging import getLogger from typing import Optional from axes.conf import settings from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler from axes.helpers import ( get_cache, get_cache_timeout, get_client_cache_key, get_client_str, get_client_username, get_credentials, get_failure_limit, ) from axes.models import AccessAttempt from axes.signals import user_locked_out log = getLogger(__name__) class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler): """ Signal handler implementation that records user login attempts to cache and locks users out if necessary. """ def __init__(self): self.cache = get_cache() def reset_attempts( self, *, ip_address: str = None, username: str = None, ip_or_username: bool = False, ) -> int: cache_keys: list = [] count = 0 if ip_address is None and username is None: raise NotImplementedError("Cannot clear all entries from cache") if ip_or_username: raise NotImplementedError( "Due to the cache key ip_or_username=True is not supported" ) cache_keys.extend( get_client_cache_key( AccessAttempt(username=username, ip_address=ip_address) ) ) for cache_key in cache_keys: deleted = self.cache.delete(cache_key) count += int(deleted) if deleted is not None else 1 log.info("AXES: Reset %d access attempts from database.", count) return count def get_failures(self, request, credentials: Optional[dict] = None) -> int: cache_keys = get_client_cache_key(request, credentials) failure_count = max( self.cache.get(cache_key, default=0) for cache_key in cache_keys ) return failure_count def user_login_failed(self, sender, credentials: dict, request=None, **kwargs): """ 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) if settings.AXES_ONLY_USER_FAILURES and username is None: log.warning( "AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created." ) return # If axes denied access, don't record the failed attempt as that would reset the lockout time. if ( not settings.AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT and request.axes_locked_out ): request.axes_credentials = credentials user_locked_out.send( "axes", request=request, username=username, ip_address=request.axes_ip_address, ) return client_str = get_client_str( username, request.axes_ip_address, request.axes_user_agent, request.axes_path_info, request, ) 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) request.axes_failures_since_start = failures_since_start 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_keys = get_client_cache_key(request, credentials) for cache_key in cache_keys: failures = self.cache.get(cache_key, default=0) self.cache.set(cache_key, failures + 1, get_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 request.axes_credentials = credentials user_locked_out.send( "axes", request=request, username=username, ip_address=request.axes_ip_address, ) def user_logged_in(self, sender, request, user, **kwargs): """ 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, request, ) log.info("AXES: Successful login by %s.", client_str) if settings.AXES_RESET_ON_SUCCESS: cache_keys = get_client_cache_key(request, credentials) for cache_key in cache_keys: 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, request, ) log.info("AXES: Successful logout by %s.", client_str) django-axes-5.39.0/axes/handlers/database.py0000644000175000017500000003113614277437635017674 0ustar jamesjamesfrom logging import getLogger from typing import Optional from django.db import transaction from django.db.models import F, Sum, Value, Q from django.db.models.functions import Concat from django.utils import timezone from axes.attempts import ( clean_expired_user_attempts, get_user_attempts, reset_user_attempts, ) from axes.conf import settings from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler from axes.helpers import ( get_client_str, get_client_username, get_credentials, get_failure_limit, get_query_str, ) from axes.models import AccessLog, AccessAttempt, AccessFailureLog from axes.signals import user_locked_out log = getLogger(__name__) class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler): """ Signal handler implementation that records user login attempts to database and locks users out if necessary. .. note:: The get_user_attempts function is called several time during the authentication and lockout process, caching its output can be dangerous. """ def reset_attempts( self, *, ip_address: Optional[str] = None, username: Optional[str] = None, ip_or_username: bool = False, ) -> int: attempts = AccessAttempt.objects.all() if ip_or_username: attempts = attempts.filter(Q(ip_address=ip_address) | Q(username=username)) else: if ip_address: attempts = attempts.filter(ip_address=ip_address) if username: attempts = attempts.filter(username=username) count, _ = attempts.delete() log.info("AXES: Reset %d access attempts from database.", count) return count def reset_logs(self, *, age_days: Optional[int] = None) -> int: if age_days is None: count, _ = AccessLog.objects.all().delete() log.info("AXES: Reset all %d access logs from database.", count) else: limit = timezone.now() - timezone.timedelta(days=age_days) count, _ = AccessLog.objects.filter(attempt_time__lte=limit).delete() log.info( "AXES: Reset %d access logs older than %d days from database.", count, age_days, ) return count def reset_failure_logs(self, *, age_days: Optional[int] = None) -> int: if age_days is None: count, _ = AccessFailureLog.objects.all().delete() log.info("AXES: Reset all %d access failure logs from database.", count) else: limit = timezone.now() - timezone.timedelta(days=age_days) count, _ = AccessFailureLog.objects.filter(attempt_time__lte=limit).delete() log.info( "AXES: Reset %d access failure logs older than %d days from database.", count, age_days, ) return count def remove_out_of_limit_failure_logs( self, *, username: str, limit: Optional[int] = settings.AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT, ) -> int: count = 0 failures = AccessFailureLog.objects.filter(username=username) out_of_limit_failures_logs = failures.count() - limit if out_of_limit_failures_logs > 0: for failure in failures[:out_of_limit_failures_logs]: failure.delete() count += 1 return count def get_failures(self, request, credentials: Optional[dict] = None) -> int: attempts_list = get_user_attempts(request, credentials) attempt_count = max( ( attempts.aggregate(Sum("failures_since_start"))[ "failures_since_start__sum" ] or 0 ) for attempts in attempts_list ) return attempt_count def user_login_failed(self, sender, credentials: dict, request=None, **kwargs): """When user login fails, save AccessFailureLog record in database, save AccessAttempt record in database, mark request with lockout attribute and emit lockout signal. """ log.info("AXES: User login failed, running database handler for failure.") 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, request, ) # If axes denied access, don't record the failed attempt as that would reset the lockout time. if ( not settings.AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT and request.axes_locked_out ): request.axes_credentials = credentials user_locked_out.send( "axes", request=request, username=username, ip_address=request.axes_ip_address, ) return # This replaces null byte chars that crash saving failures. get_data = get_query_str(request.GET).replace("\0", "0x00") post_data = get_query_str(request.POST).replace("\0", "0x00") if self.is_whitelisted(request, credentials): log.info("AXES: Login failed from whitelisted client %s.", client_str) return # 2. database query: Get or create access record with the new failure data if settings.AXES_ONLY_USER_FAILURES and username is None: log.warning( "AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created." ) else: with transaction.atomic(): ( attempt, created, ) = AccessAttempt.objects.select_for_update().get_or_create( username=username, ip_address=request.axes_ip_address, user_agent=request.axes_user_agent, defaults={ "get_data": get_data, "post_data": post_data, "http_accept": request.axes_http_accept, "path_info": request.axes_path_info, "failures_since_start": 1, "attempt_time": request.axes_attempt_time, }, ) # 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. if created: log.warning( "AXES: New login failure by %s. Created new record in the database.", client_str, ) # 3. database query if there were previous attempts in the database # Update failed attempt information but do not touch the username, IP address, or user agent fields, # because attackers can request the site with multiple different configurations # in order to bypass the defense mechanisms that are used by the site. else: separator = "\n---------\n" attempt.get_data = Concat("get_data", Value(separator + get_data)) attempt.post_data = Concat( "post_data", Value(separator + post_data) ) attempt.http_accept = request.axes_http_accept attempt.path_info = request.axes_path_info attempt.failures_since_start = F("failures_since_start") + 1 attempt.attempt_time = request.axes_attempt_time attempt.save() log.warning( "AXES: Repeated login failure by %s. Updated existing record in the database.", client_str, ) # 3. or 4. database query: Calculate the current maximum failure number from the existing attempts failures_since_start = self.get_failures(request, credentials) request.axes_failures_since_start = failures_since_start 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 request.axes_credentials = credentials user_locked_out.send( "axes", request=request, username=username, ip_address=request.axes_ip_address, ) # 5. database entry: Log for ever the attempt in the AccessFailureLog if settings.AXES_ENABLE_ACCESS_FAILURE_LOG: with transaction.atomic(): AccessFailureLog.objects.create( username=username, ip_address=request.axes_ip_address, user_agent=request.axes_user_agent, http_accept=request.axes_http_accept, path_info=request.axes_path_info, attempt_time=request.axes_attempt_time, locked_out=request.axes_locked_out, ) self.remove_out_of_limit_failure_logs(username=username) def user_logged_in(self, sender, request, user, **kwargs): """ When user logs in, update the AccessLog related to the user. """ # 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, request, ) 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): """ 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, request, ) 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) def post_save_access_attempt(self, instance, **kwargs): """ Handles the ``axes.models.AccessAttempt`` object post save signal. When needed, all post_save actions for this backend should be located here. """ def post_delete_access_attempt(self, instance, **kwargs): """ Handles the ``axes.models.AccessAttempt`` object post delete signal. When needed, all post_delete actions for this backend should be located here. """ django-axes-5.39.0/axes/handlers/dummy.py0000644000175000017500000000132514277437635017260 0ustar jamesjamesfrom axes.handlers.base import AxesBaseHandler, AbstractAxesHandler from typing import Optional class AxesDummyHandler(AbstractAxesHandler, AxesBaseHandler): """ Signal handler implementation that does nothing and can be used to disable signal processing. """ def is_allowed(self, request, credentials: Optional[dict] = None) -> bool: return True def user_login_failed(self, sender, credentials: dict, request=None, **kwargs): pass def user_logged_in(self, sender, request, user, **kwargs): pass def user_logged_out(self, sender, request, user, **kwargs): pass def get_failures(self, request, credentials: Optional[dict] = None) -> int: return 0 django-axes-5.39.0/axes/handlers/proxy.py0000644000175000017500000001203614277437635017307 0ustar jamesjames# pylint: disable=arguments-differ # pylint generates false negatives from proxy class method overrides from logging import getLogger from typing import Optional from django.utils.module_loading import import_string from django.utils.timezone import now from axes.conf import settings from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler, AxesHandler from axes.helpers import ( get_client_ip_address, get_client_user_agent, get_client_path_info, get_client_http_accept, toggleable, ) log = getLogger(__name__) class AxesProxyHandler(AbstractAxesHandler, AxesBaseHandler): """ 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 @classmethod def reset_attempts( cls, *, ip_address: Optional[str] = None, username: Optional[str] = None, ip_or_username: bool = False, ) -> int: return cls.get_implementation().reset_attempts( ip_address=ip_address, username=username, ip_or_username=ip_or_username ) @classmethod def reset_logs(cls, *, age_days: Optional[int] = None) -> int: return cls.get_implementation().reset_logs(age_days=age_days) @classmethod def reset_failure_logs(cls, *, age_days: Optional[int] = None) -> int: return cls.get_implementation().reset_failure_logs(age_days=age_days) @classmethod def remove_out_of_limit_failure_logs( cls, *, username: str, limit: Optional[int] = None ) -> int: return cls.get_implementation().remove_out_of_limit_failure_logs( username=username ) @staticmethod def update_request(request): """ 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 if not hasattr(request, "axes_updated"): if not hasattr(request, "axes_locked_out"): request.axes_locked_out = False request.axes_attempt_time = now() request.axes_ip_address = get_client_ip_address(request) request.axes_user_agent = get_client_user_agent(request) request.axes_path_info = get_client_path_info(request) request.axes_http_accept = get_client_http_accept(request) request.axes_failures_since_start = None request.axes_updated = True request.axes_credentials = None @classmethod def is_locked(cls, request, credentials: Optional[dict] = None) -> bool: cls.update_request(request) return cls.get_implementation().is_locked(request, credentials) @classmethod def is_allowed(cls, request, credentials: Optional[dict] = None) -> bool: cls.update_request(request) return cls.get_implementation().is_allowed(request, credentials) @classmethod def get_failures(cls, request, credentials: Optional[dict] = None) -> int: cls.update_request(request) return cls.get_implementation().get_failures(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 @toggleable def post_delete_access_attempt(cls, instance, **kwargs): return cls.get_implementation().post_delete_access_attempt(instance, **kwargs) django-axes-5.39.0/axes/handlers/test.py0000644000175000017500000000132214277437635017101 0ustar jamesjamesfrom axes.handlers.base import AxesHandler from typing import Optional class AxesTestHandler(AxesHandler): # pylint: disable=unused-argument """ Signal handler implementation that does nothing, ideal for a test suite. """ def reset_attempts( self, *, ip_address: Optional[str] = None, username: Optional[str] = None, ip_or_username: bool = False, ) -> int: return 0 def reset_logs(self, *, age_days: Optional[int] = None) -> int: return 0 def is_allowed(self, request, credentials: Optional[dict] = None) -> bool: return True def get_failures(self, request, credentials: Optional[dict] = None) -> int: return 0 django-axes-5.39.0/axes/helpers.py0000644000175000017500000004442714277437635016001 0ustar jamesjamesfrom datetime import timedelta from hashlib import sha256 from logging import getLogger from string import Template from typing import Callable, Optional, Type, Union from urllib.parse import urlencode import ipware.ip from django.core.cache import caches, BaseCache from django.http import HttpRequest, HttpResponse, JsonResponse, QueryDict from django.shortcuts import render, redirect from django.utils.module_loading import import_string from axes.conf import settings from axes.models import AccessBase 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, integer, callable, or str path, and this function offers a unified _integer or None_ representation of that configuration for use with the Django cache backends. """ 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) if isinstance(cool_off, float): return timedelta(minutes=cool_off * 60) if isinstance(cool_off, str): return import_string(cool_off)() if callable(cool_off): return cool_off() # pylint: disable=not-callable 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: Optional[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: HttpRequest, credentials: Optional[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( # pylint: disable=not-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" ) request_data = getattr(request, "data", request.POST) return request_data.get(settings.AXES_USERNAME_FORM_FIELD, None) def get_client_ip_address(request: HttpRequest) -> 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.ip.get_client_ip( request, proxy_order=settings.AXES_PROXY_ORDER, proxy_count=settings.AXES_PROXY_COUNT, proxy_trusted_ips=settings.AXES_PROXY_TRUSTED_IPS, request_header_order=settings.AXES_META_PRECEDENCE_ORDER, ) return client_ip_address def get_client_user_agent(request: HttpRequest) -> str: return request.META.get("HTTP_USER_AGENT", "")[:255] def get_client_path_info(request: HttpRequest) -> str: return request.META.get("PATH_INFO", "")[:255] def get_client_http_accept(request: HttpRequest) -> str: return request.META.get("HTTP_ACCEPT", "")[:1025] def get_client_parameters(username: str, ip_address: str, user_agent: str) -> list: """ 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. Returns list of dict, every item of list are separate parameters """ if settings.AXES_ONLY_USER_FAILURES: # 1. Only individual usernames can be tracked with parametrization filter_query = [{"username": username}] else: if settings.AXES_LOCK_OUT_BY_USER_OR_IP: # One of `username` or `IP address` is used filter_query = [{"username": username}, {"ip_address": ip_address}] elif settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP: # 2. A combination of username and IP address can be used as well filter_query = [{"username": username, "ip_address": ip_address}] else: # 3. Default case is to track the IP address only, which is the most secure option filter_query = [{"ip_address": ip_address}] if settings.AXES_USE_USER_AGENT: # 4. The HTTP User-Agent can be used to track e.g. one browser filter_query.append({"user_agent": user_agent}) return filter_query def make_cache_key_list(filter_kwargs_list): cache_keys = [] for filter_kwargs in filter_kwargs_list: cache_key_components = "".join( value for value in filter_kwargs.values() if value ) cache_key_digest = sha256(cache_key_components.encode()).hexdigest() cache_keys.append(f"axes-{cache_key_digest}") return cache_keys def get_client_cache_key( request_or_attempt: Union[HttpRequest, AccessBase], credentials: Optional[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, AccessBase): username = request_or_attempt.username ip_address = request_or_attempt.ip_address user_agent = request_or_attempt.user_agent else: 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) filter_kwargs_list = get_client_parameters(username, ip_address, user_agent) return make_cache_key_list(filter_kwargs_list) def get_client_str( username: str, ip_address: str, user_agent: str, path_info: str, request: HttpRequest, ) -> 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/"}`` """ if settings.AXES_CLIENT_STR_CALLABLE: log.debug("Using settings.AXES_CLIENT_STR_CALLABLE to get client string.") if callable(settings.AXES_CLIENT_STR_CALLABLE): return settings.AXES_CLIENT_STR_CALLABLE( # pylint: disable=not-callable username, ip_address, user_agent, path_info, request ) if isinstance(settings.AXES_CLIENT_STR_CALLABLE, str): return import_string(settings.AXES_CLIENT_STR_CALLABLE)( username, ip_address, user_agent, path_info, request ) raise TypeError( "settings.AXES_CLIENT_STR_CALLABLE needs to be a string, callable or None." ) client_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_list = get_client_parameters(username, ip_address, user_agent) client_dict = {} for client in client_list: client_dict.update(client) # 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 = Template('$key: "$value"') items = [{"key": k, "value": v} for k, v in client_dict.items()] client_str = ", ".join(template.substitute(item) for item in items) client_str = "{" + client_str + "}" return client_str def cleanse_parameters(params: dict) -> dict: """ Replace sensitive parameter values in a parameter dict with a safe placeholder value. Parameters name ``'password'`` will always be cleansed. Additionally, parameters named in ``settings.AXES_SENSITIVE_PARAMETERS`` and ``settings.AXES_PASSWORD_FORM_FIELD will be cleansed. This is used to prevent passwords and similar values from being logged in cleartext. """ sensitive_parameters = ["password"] + settings.AXES_SENSITIVE_PARAMETERS if settings.AXES_PASSWORD_FORM_FIELD: sensitive_parameters.append(settings.AXES_PASSWORD_FORM_FIELD) if sensitive_parameters: cleansed = params.copy() for param in sensitive_parameters: if param in cleansed: cleansed[param] = "********************" return cleansed return params 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`` or if the fieldname is included in ``settings.AXES_SENSITIVE_PARAMETERS`` its value will be masked. The length of the output is limited to max_length to avoid a DoS attack via excessively large payloads. """ query_dict = cleanse_parameters(query.copy()) template = Template("$key=$value") items = [{"key": k, "value": v} for k, v in query_dict.items()] query_str = "\n".join(template.substitute(item) for item in items) return query_str[:max_length] def get_failure_limit(request: HttpRequest, credentials) -> int: if callable(settings.AXES_FAILURE_LIMIT): return settings.AXES_FAILURE_LIMIT( # pylint: disable=not-callable request, credentials ) if isinstance(settings.AXES_FAILURE_LIMIT, str): return import_string(settings.AXES_FAILURE_LIMIT)(request, credentials) if isinstance(settings.AXES_FAILURE_LIMIT, int): 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: HttpRequest, credentials: Optional[dict] = None ) -> HttpResponse: if settings.AXES_LOCKOUT_CALLABLE: if callable(settings.AXES_LOCKOUT_CALLABLE): return settings.AXES_LOCKOUT_CALLABLE( # pylint: disable=not-callable request, credentials ) if isinstance(settings.AXES_LOCKOUT_CALLABLE, str): return import_string(settings.AXES_LOCKOUT_CALLABLE)(request, credentials) raise TypeError( "settings.AXES_LOCKOUT_CALLABLE needs to be a string, callable, or None." ) status = settings.AXES_HTTP_RESPONSE_CODE 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 "cooloff_timedelta": cool_off, } ) if request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest": json_response = JsonResponse(context, status=status) json_response[ "Access-Control-Allow-Origin" ] = settings.AXES_ALLOWED_CORS_ORIGINS json_response["Access-Control-Allow-Methods"] = "POST, OPTIONS" json_response[ "Access-Control-Allow-Headers" ] = "Origin, Content-Type, Accept, Authorization, x-requested-with" return json_response if settings.AXES_LOCKOUT_TEMPLATE: return render(request, settings.AXES_LOCKOUT_TEMPLATE, context, status=status) if settings.AXES_LOCKOUT_URL: lockout_url = settings.AXES_LOCKOUT_URL query_string = urlencode({"username": context["username"]}) url = f"{lockout_url}?{query_string}" return redirect(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 ( # pylint: disable=unsupported-membership-test ip_address in settings.AXES_IP_WHITELIST ) def is_ip_address_in_blacklist(ip_address: str) -> bool: if not settings.AXES_IP_BLACKLIST: return False return ( # pylint: disable=unsupported-membership-test ip_address in settings.AXES_IP_BLACKLIST ) def is_client_ip_address_whitelisted(request: HttpRequest): """ 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: HttpRequest) -> 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: HttpRequest) -> 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 is_user_attempt_whitelisted( request: HttpRequest, credentials: Optional[dict] = None ) -> bool: """ Check if the given request or credentials refer to a whitelisted username. This method invokes the ``settings.AXES_WHITELIST`` callable with ``request`` and ``credentials`` arguments. This function could use the following implementation for checking the lockout flags from a specific property in the user object: .. code-block: python username_value = get_client_username(request, credentials) username_field = getattr( get_user_model(), "USERNAME_FIELD", "username" ) kwargs = {username_field: username_value} user_model = get_user_model() user = user_model.objects.get(**kwargs) return user.nolockout """ whitelist_callable = settings.AXES_WHITELIST_CALLABLE if whitelist_callable is None: return False if callable(whitelist_callable): return whitelist_callable(request, credentials) # pylint: disable=not-callable if isinstance(whitelist_callable, str): return import_string(whitelist_callable)(request, credentials) raise TypeError( "settings.AXES_WHITELIST_CALLABLE needs to be a string, callable, or None." ) 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.39.0/axes/locale/0000755000175000017500000000000014277437635015211 5ustar jamesjamesdjango-axes-5.39.0/axes/locale/ar/0000755000175000017500000000000014277437635015613 5ustar jamesjamesdjango-axes-5.39.0/axes/locale/ar/LC_MESSAGES/0000755000175000017500000000000014277437635017400 5ustar jamesjamesdjango-axes-5.39.0/axes/locale/ar/LC_MESSAGES/django.mo0000644000175000017500000000374214277437635021205 0ustar jamesjamesŪ•Ė|đņQ@S ” Ą ¯š Â Î Ų å īų ū !1@ P [•gũy’"+:f‚ “ Ą¯Îî6Nd|—ļĘ    Access lock outAccount locked: too many login attempts. Contact an admin to unlock your account.Account locked: too many login attempts. Please try again later.Attempt TimeFailed LoginsForm DataGET DataHTTP AcceptIP AddressLogout TimeMeta DataPOST DataPathUser AgentUsernameaccess attemptaccess attemptsaccess failureaccess failuresaccess logaccess logsProject-Id-Version: PACKAGE VERSION Report-Msgid-Bugs-To: 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=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5; Ų…Ų‚ŲŠØ¯ Ų…Ų† Ø§Ų„Ø¯ØŽŲˆŲ„Ø§Ų„Ø­ØŗØ§Ø¨ Ų…ØēŲ„Ų‚: Ų…Ø­Ø§ŲˆŲ„Ø§ØĒ ØĒØŗØŦŲŠŲ„ Ø¯ØŽŲˆŲ„ ؃ØĢŲŠØąØŠ ØŦØ¯Ų‹Ø§. اØĒØĩŲ„ Ø¨Ų…ØŗØ¤ŲˆŲ„ ؄؁ØĒØ­ Ø­ØŗØ§Ø¨Ųƒ.Ø§Ų„Ø­ØŗØ§Ø¨ Ų…ØēŲ„Ų‚: Ų…Ø­Ø§ŲˆŲ„Ø§ØĒ ØĒØŗØŦŲŠŲ„ Ø¯ØŽŲˆŲ„ ؃ØĢŲŠØąØŠ ØŦØ¯Ų‹Ø§. Ø§Ų„ØąØŦØ§ØĄ Ų…ØšØ§ŲˆØ¯ØŠ Ø§Ų„Ų…Ø­Ø§ŲˆŲ„ØŠ ؁؊ ŲˆŲ‚ØĒ Ų„Ø§Ø­Ų‚.ŲˆŲ‚ØĒ Ø§Ų„Ų…Ø­Ø§ŲˆŲ„ØŠØšŲ…Ų„ŲŠØ§ØĒ ØĒØŗØŦŲŠŲ„ Ø¯ØŽŲˆŲ„ ŲØ§Ø´Ų„ØŠØ¨ŲŠØ§Ų†Ø§ØĒ Ø§Ų„Ų†Ų…ŲˆØ°ØŦGET Ø¨ŲŠØ§Ų†Ø§ØĒŲ‚Ø¨ŲˆŲ„ HTTPØšŲ†ŲˆØ§Ų† IPŲˆŲ‚ØĒ ØĒØŗØŦŲŠŲ„ Ø§Ų„ØŽØąŲˆØŦØ§Ų„Ø¨ŲŠØ§Ų†Ø§ØĒ Ø§Ų„ŲˆØĩŲŲŠØŠPOST Ø¨ŲŠØ§Ų†Ø§ØĒŲ…ØšŲ„ŲˆŲ…Ø§ØĒ Ø§Ų„Ų…ØŗØ§ØąŲˆŲƒŲŠŲ„ Ø§Ų„Ų…ØŗØĒØŽØ¯Ų…Ø§ØŗŲ… Ø§Ų„Ų…ØŗØĒØŽØ¯Ų…Ų…Ø­Ø§ŲˆŲ„ØŠ Ø¯ØŽŲˆŲ„Ų…Ø­Ø§ŲˆŲ„Ø§ØĒ Ø¯ØŽŲˆŲ„ØŗØŦŲ„ Ø¯ØŽŲˆŲ„ ŲØ§Ø´Ų„ØŠØŗØŦŲ„Ø§ØĒ Ø¯ØŽŲˆŲ„ ŲØ§Ø´Ų„ØŠØŗØŦŲ„ Ø§Ų„Ø¯ØŽŲˆŲ„ØŗØŦŲ„Ø§ØĒ Ø§Ų„Ø¯ØŽŲˆŲ„django-axes-5.39.0/axes/locale/ar/LC_MESSAGES/django.po0000644000175000017500000000500614277437635021203 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: 2022-05-30 15:16+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=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 " "&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" #: admin.py:27 msgid "Form Data" msgstr "Ø¨ŲŠØ§Ų†Ø§ØĒ Ø§Ų„Ų†Ų…ŲˆØ°ØŦ" #: admin.py:28 admin.py:65 admin.py:100 msgid "Meta Data" msgstr "Ø§Ų„Ø¨ŲŠØ§Ų†Ø§ØĒ Ø§Ų„ŲˆØĩŲŲŠØŠ" #: conf.py:97 msgid "Account locked: too many login attempts. Please try again later." msgstr "Ø§Ų„Ø­ØŗØ§Ø¨ Ų…ØēŲ„Ų‚: Ų…Ø­Ø§ŲˆŲ„Ø§ØĒ ØĒØŗØŦŲŠŲ„ Ø¯ØŽŲˆŲ„ ؃ØĢŲŠØąØŠ ØŦØ¯Ų‹Ø§. Ø§Ų„ØąØŦØ§ØĄ Ų…ØšØ§ŲˆØ¯ØŠ Ø§Ų„Ų…Ø­Ø§ŲˆŲ„ØŠ ؁؊ ŲˆŲ‚ØĒ Ų„Ø§Ø­Ų‚." #: conf.py:105 msgid "" "Account locked: too many login attempts. Contact an admin to unlock your " "account." msgstr "Ø§Ų„Ø­ØŗØ§Ø¨ Ų…ØēŲ„Ų‚: Ų…Ø­Ø§ŲˆŲ„Ø§ØĒ ØĒØŗØŦŲŠŲ„ Ø¯ØŽŲˆŲ„ ؃ØĢŲŠØąØŠ ØŦØ¯Ų‹Ø§. اØĒØĩŲ„ Ø¨Ų…ØŗØ¤ŲˆŲ„ ؄؁ØĒØ­ Ø­ØŗØ§Ø¨Ųƒ." #: models.py:6 msgid "User Agent" msgstr "ŲˆŲƒŲŠŲ„ Ø§Ų„Ų…ØŗØĒØŽØ¯Ų…" #: models.py:8 msgid "IP Address" msgstr "ØšŲ†ŲˆØ§Ų† IP" #: models.py:10 msgid "Username" msgstr "Ø§ØŗŲ… Ø§Ų„Ų…ØŗØĒØŽØ¯Ų…" #: models.py:12 msgid "HTTP Accept" msgstr "Ų‚Ø¨ŲˆŲ„ HTTP" #: models.py:14 msgid "Path" msgstr "Ų…ØšŲ„ŲˆŲ…Ø§ØĒ Ø§Ų„Ų…ØŗØ§Øą" #: models.py:16 msgid "Attempt Time" msgstr "ŲˆŲ‚ØĒ Ø§Ų„Ų…Ø­Ø§ŲˆŲ„ØŠ" #: models.py:26 msgid "Access lock out" msgstr "Ų…Ų‚ŲŠØ¯ Ų…Ų† Ø§Ų„Ø¯ØŽŲˆŲ„" #: models.py:34 msgid "access failure" msgstr "ØŗØŦŲ„ Ø¯ØŽŲˆŲ„ ŲØ§Ø´Ų„ØŠ" #: models.py:35 msgid "access failures" msgstr "ØŗØŦŲ„Ø§ØĒ Ø¯ØŽŲˆŲ„ ŲØ§Ø´Ų„ØŠ" #: models.py:39 msgid "GET Data" msgstr "GET Ø¨ŲŠØ§Ų†Ø§ØĒ" #: models.py:41 msgid "POST Data" msgstr "POST Ø¨ŲŠØ§Ų†Ø§ØĒ" #: models.py:43 msgid "Failed Logins" msgstr "ØšŲ…Ų„ŲŠØ§ØĒ ØĒØŗØŦŲŠŲ„ Ø¯ØŽŲˆŲ„ ŲØ§Ø´Ų„ØŠ" #: models.py:49 msgid "access attempt" msgstr "Ų…Ø­Ø§ŲˆŲ„ØŠ Ø¯ØŽŲˆŲ„" #: models.py:50 msgid "access attempts" msgstr "Ų…Ø­Ø§ŲˆŲ„Ø§ØĒ Ø¯ØŽŲˆŲ„" #: models.py:55 msgid "Logout Time" msgstr "ŲˆŲ‚ØĒ ØĒØŗØŦŲŠŲ„ Ø§Ų„ØŽØąŲˆØŦ" #: models.py:61 msgid "access log" msgstr "ØŗØŦŲ„ Ø§Ų„Ø¯ØŽŲˆŲ„" #: models.py:62 msgid "access logs" msgstr "ØŗØŦŲ„Ø§ØĒ Ø§Ų„Ø¯ØŽŲˆŲ„" django-axes-5.39.0/axes/locale/de/0000755000175000017500000000000014277437635015601 5ustar jamesjamesdjango-axes-5.39.0/axes/locale/de/LC_MESSAGES/0000755000175000017500000000000014277437635017366 5ustar jamesjamesdjango-axes-5.39.0/axes/locale/de/LC_MESSAGES/django.mo0000644000175000017500000000325714277437635021174 0ustar jamesjamesŪ•Ė|đņQ@S ” Ą ¯š Â Î Ų å īų ū !1@ P [BgĒģa;¯ Ī Ú ä đû  "' 6CSd} – ĸ    Access lock outAccount locked: too many login attempts. Contact an admin to unlock your account.Account locked: too many login attempts. Please try again later.Attempt TimeFailed LoginsForm DataGET DataHTTP AcceptIP AddressLogout TimeMeta DataPOST DataPathUser AgentUsernameaccess attemptaccess attemptsaccess failureaccess failuresaccess logaccess logsProject-Id-Version: PACKAGE VERSION Report-Msgid-Bugs-To: 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); Zugriff gesperrtZugang 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-DatenHTTP-AcceptIP-AdresseAbmeldezeitpunktMeta-DatenPOST-DatenPfadBrowserkennungBenutzernameZugriffsversuchZugriffsversucheFehlgeschlagener ZugriffFehlgeschlagene ZugriffeZugriffslogZugriffslogsdjango-axes-5.39.0/axes/locale/de/LC_MESSAGES/django.po0000644000175000017500000000470014277437635021171 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: 2022-05-27 11:46+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:27 msgid "Form Data" msgstr "Form-Daten" #: .\axes\admin.py:28 .\axes\admin.py:65 .\axes\admin.py:100 msgid "Meta Data" msgstr "Meta-Daten" #: .\axes\conf.py:97 msgid "Account locked: too many login attempts. Please try again later." msgstr "" "Zugang gesperrt: zu viele fehlgeschlagene Anmeldeversuche. Bitte versuchen " "Sie es später erneut." #: .\axes\conf.py:105 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:6 msgid "User Agent" msgstr "Browserkennung" #: .\axes\models.py:8 msgid "IP Address" msgstr "IP-Adresse" #: .\axes\models.py:10 msgid "Username" msgstr "Benutzername" #: .\axes\models.py:12 msgid "HTTP Accept" msgstr "HTTP-Accept" #: .\axes\models.py:14 msgid "Path" msgstr "Pfad" #: .\axes\models.py:16 msgid "Attempt Time" msgstr "Zugriffszeitpunkt" #: .\axes\models.py:26 #| msgid "access log" msgid "Access lock out" msgstr "Zugriff gesperrt" #: .\axes\models.py:34 #| msgid "access log" msgid "access failure" msgstr "Fehlgeschlagener Zugriff" #: .\axes\models.py:35 #| msgid "access logs" msgid "access failures" msgstr "Fehlgeschlagene Zugriffe" #: .\axes\models.py:39 msgid "GET Data" msgstr "GET-Daten" #: .\axes\models.py:41 msgid "POST Data" msgstr "POST-Daten" #: .\axes\models.py:43 msgid "Failed Logins" msgstr "Fehlgeschlagene Anmeldeversuche" #: .\axes\models.py:49 msgid "access attempt" msgstr "Zugriffsversuch" #: .\axes\models.py:50 msgid "access attempts" msgstr "Zugriffsversuche" #: .\axes\models.py:55 msgid "Logout Time" msgstr "Abmeldezeitpunkt" #: .\axes\models.py:61 msgid "access log" msgstr "Zugriffslog" #: .\axes\models.py:62 msgid "access logs" msgstr "Zugriffslogs" django-axes-5.39.0/axes/locale/pl/0000755000175000017500000000000014277437635015624 5ustar jamesjamesdjango-axes-5.39.0/axes/locale/pl/LC_MESSAGES/0000755000175000017500000000000014277437635017411 5ustar jamesjamesdjango-axes-5.39.0/axes/locale/pl/LC_MESSAGES/django.mo0000644000175000017500000000276614277437635021223 0ustar jamesjamesŪ•¤,ˆQ‰@Û  ) 7A J U a kuzƒ’ ĸ ­˛šnlKÛ'9L\en ˆ ’œ¯ŋĪâ   Account locked: too many login attempts. Contact an admin to unlock your account.Account locked: too many login attempts. Please try again later.Attempt TimeFailed LoginsForm DataGET DataIP AddressLogout TimeMeta DataPOST DataPathUsernameaccess attemptaccess attemptsaccess logaccess logsProject-Id-Version: Report-Msgid-Bugs-To: PO-Revision-Date: 2021-06-16 10:51+0300 Language: pl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3); Last-Translator: Language-Team: X-Generator: Poedit 3.0 Konto zablokowane: zbyt wiele prÃŗb logowania. Skontaktuj się z administratorem, aby odblokować swoje konto.Konto zablokowane: zbyt wiele prÃŗb logowania. SprÃŗbuj ponownie pÃŗÅēniej.Czas wystąpieniaNieudane logowaniaDane formularzaDane GETAdres IPCzas wylogowaniaMetadaneDane POSTŚcieÅŧkaNazwa UÅŧytkownikaprÃŗba dostępuprÃŗby dostępudziennik logowaniadzienniki logowaniadjango-axes-5.39.0/axes/locale/pl/LC_MESSAGES/django.po0000644000175000017500000000433214277437635021215 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. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-06-11 23:36+0200\n" "PO-Revision-Date: 2021-06-16 10:51+0300\n" "Language: pl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n" "%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n" "%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" "Last-Translator: \n" "Language-Team: \n" "X-Generator: Poedit 3.0\n" #: .\axes\admin.py:26 msgid "Form Data" msgstr "Dane formularza" #: .\axes\admin.py:27 .\axes\admin.py:64 msgid "Meta Data" msgstr "Metadane" #: .\axes\conf.py:89 msgid "Account locked: too many login attempts. Please try again later." msgstr "" "Konto zablokowane: zbyt wiele prÃŗb logowania. SprÃŗbuj ponownie pÃŗÅēniej." #: .\axes\conf.py:97 msgid "" "Account locked: too many login attempts. Contact an admin to unlock your " "account." msgstr "" "Konto zablokowane: zbyt wiele prÃŗb logowania. Skontaktuj się z " "administratorem, aby odblokować swoje konto." #: .\axes\models.py:6 #, fuzzy msgid "User Agent" msgstr "User Agent" #: .\axes\models.py:8 msgid "IP Address" msgstr "Adres IP" #: .\axes\models.py:10 msgid "Username" msgstr "Nazwa UÅŧytkownika" #: .\axes\models.py:12 #, fuzzy msgid "HTTP Accept" msgstr "HTTP Accept" #: .\axes\models.py:14 msgid "Path" msgstr "ŚcieÅŧka" #: .\axes\models.py:16 msgid "Attempt Time" msgstr "Czas wystąpienia" #: .\axes\models.py:25 msgid "GET Data" msgstr "Dane GET" #: .\axes\models.py:27 msgid "POST Data" msgstr "Dane POST" #: .\axes\models.py:29 msgid "Failed Logins" msgstr "Nieudane logowania" #: .\axes\models.py:35 msgid "access attempt" msgstr "prÃŗba dostępu" #: .\axes\models.py:36 msgid "access attempts" msgstr "prÃŗby dostępu" #: .\axes\models.py:40 msgid "Logout Time" msgstr "Czas wylogowania" #: .\axes\models.py:46 msgid "access log" msgstr "dziennik logowania" #: .\axes\models.py:47 msgid "access logs" msgstr "dzienniki logowania" django-axes-5.39.0/axes/locale/ru/0000755000175000017500000000000014277437635015637 5ustar jamesjamesdjango-axes-5.39.0/axes/locale/ru/LC_MESSAGES/0000755000175000017500000000000014277437635017424 5ustar jamesjamesdjango-axes-5.39.0/axes/locale/ru/LC_MESSAGES/django.mo0000644000175000017500000000356614277437635021235 0ustar jamesjamesŪ•´LĀQÁ@ T a oy ‚ Ž ™ Ĩ ¯š žÉŌá ņ üBäKš0Ë!á; M[s ˆŠ'˛Ú-ķ!?^   Account locked: too many login attempts. Contact an admin to unlock your account.Account locked: too many login attempts. Please try again later.Attempt TimeFailed LoginsForm DataGET DataHTTP AcceptIP AddressLogout TimeMeta DataPOST DataPathUser AgentUsernameaccess attemptaccess attemptsaccess logaccess logsProject-Id-Version: PACKAGE VERSION Report-Msgid-Bugs-To: 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.39.0/axes/locale/ru/LC_MESSAGES/django.po0000644000175000017500000000467714277437635021244 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.39.0/axes/locale/tr/0000755000175000017500000000000014277437635015636 5ustar jamesjamesdjango-axes-5.39.0/axes/locale/tr/LC_MESSAGES/0000755000175000017500000000000014277437635017423 5ustar jamesjamesdjango-axes-5.39.0/axes/locale/tr/LC_MESSAGES/django.mo0000644000175000017500000000260414277437635021224 0ustar jamesjamesŪ•¤,ˆQ‰@Û  ) 7A J U a kuzƒ’ ĸ ­BšhüRe¸É Ũ é ôū  (,=Nap   Account locked: too many login attempts. Contact an admin to unlock your account.Account locked: too many login attempts. Please try again later.Attempt TimeFailed LoginsForm DataGET DataIP AddressLogout TimeMeta DataPOST DataPathUsernameaccess attemptaccess attemptsaccess logaccess logsProject-Id-Version: PACKAGE VERSION Report-Msgid-Bugs-To: 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çin.Hesap kilitlendi: cok fazla erişim denemesi. LÃŧtfen daha sonra tekrar deneyiniz.Giriş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.39.0/axes/locale/tr/LC_MESSAGES/django.po0000644000175000017500000000403314277437635021225 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.39.0/axes/management/0000755000175000017500000000000014277437635016066 5ustar jamesjamesdjango-axes-5.39.0/axes/management/__init__.py0000644000175000017500000000000014277437635020165 0ustar jamesjamesdjango-axes-5.39.0/axes/management/commands/0000755000175000017500000000000014277437635017667 5ustar jamesjamesdjango-axes-5.39.0/axes/management/commands/__init__.py0000644000175000017500000000000014277437635021766 0ustar jamesjamesdjango-axes-5.39.0/axes/management/commands/axes_list_attempts.py0000644000175000017500000000056114277437635024157 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): for obj in AccessAttempt.objects.all(): self.stdout.write( f"{obj.ip_address}\t{obj.username}\t{obj.failures_since_start}" ) django-axes-5.39.0/axes/management/commands/axes_reset.py0000644000175000017500000000056614277437635022412 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): count = reset() if count: self.stdout.write(f"{count} attempts removed.") else: self.stdout.write("No attempts found.") django-axes-5.39.0/axes/management/commands/axes_reset_failure_logs.py0000644000175000017500000000122314277437635025134 0ustar jamesjamesfrom django.core.management.base import BaseCommand from axes.handlers.proxy import AxesProxyHandler class Command(BaseCommand): help = "Reset access failure log records older than given days." def add_arguments(self, parser): parser.add_argument( "--age", type=int, default=30, help="Maximum age for records to keep in days", ) def handle(self, *args, **options): count = AxesProxyHandler.reset_failure_logs(age_days=options["age"]) if count: self.stdout.write(f"{count} logs removed.") else: self.stdout.write("No logs found.") django-axes-5.39.0/axes/management/commands/axes_reset_ip.py0000644000175000017500000000105014277437635023067 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.39.0/axes/management/commands/axes_reset_logs.py0000644000175000017500000000120314277437635023423 0ustar jamesjamesfrom django.core.management.base import BaseCommand from axes.handlers.proxy import AxesProxyHandler class Command(BaseCommand): help = "Reset access log records older than given days." def add_arguments(self, parser): parser.add_argument( "--age", type=int, default=30, help="Maximum age for records to keep in days", ) def handle(self, *args, **options): count = AxesProxyHandler.reset_logs(age_days=options["age"]) if count: self.stdout.write(f"{count} logs removed.") else: self.stdout.write("No logs found.") django-axes-5.39.0/axes/management/commands/axes_reset_username.py0000644000175000017500000000110314277437635024275 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.39.0/axes/middleware.py0000644000175000017500000000320614277437635016442 0ustar jamesjamesfrom typing import Callable from django.conf import settings from django.http import HttpRequest, HttpResponse 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. If a project uses ``django rest framework`` then the middleware updates the request and checks whether the limit has been exceeded. It's needed only for integration with DRF because it uses its own request object. 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) -> None: self.get_response = get_response def __call__(self, request: HttpRequest) -> HttpResponse: response = self.get_response(request) if settings.AXES_ENABLED: if getattr(request, "axes_locked_out", None): credentials = getattr(request, "axes_credentials", None) response = get_lockout_response(request, credentials) # type: ignore return response django-axes-5.39.0/axes/migrations/0000755000175000017500000000000014277437635016126 5ustar jamesjamesdjango-axes-5.39.0/axes/migrations/0001_initial.py0000644000175000017500000000541714277437635020600 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.39.0/axes/migrations/0002_auto_20151217_2044.py0000644000175000017500000000327314277437635021551 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.39.0/axes/migrations/0003_auto_20160322_0929.py0000644000175000017500000000363414277437635021562 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.39.0/axes/migrations/0004_auto_20181024_1538.py0000644000175000017500000000422514277437635021557 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.39.0/axes/migrations/0005_remove_accessattempt_trusted.py0000644000175000017500000000033314277437635025132 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.39.0/axes/migrations/0006_remove_accesslog_trusted.py0000644000175000017500000000042214277437635024235 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.39.0/axes/migrations/0007_alter_accessattempt_unique_together.py0000644000175000017500000000213414277437635026464 0ustar jamesjames# Generated by Django 3.2.7 on 2021-09-13 15:16 from django.db import migrations from django.db.models import Count def deduplicate_attempts(apps, schema_editor): AccessAttempt = apps.get_model("axes", "AccessAttempt") duplicated_attempts = ( AccessAttempt.objects.values("username", "user_agent", "ip_address") .annotate(Count("id")) .order_by() .filter(id__count__gt=1) ) for attempt in duplicated_attempts: redundant_attempts = AccessAttempt.objects.filter( username=attempt["username"], user_agent=attempt["user_agent"], ip_address=attempt["ip_address"], )[1:] for redundant_attempt in redundant_attempts: redundant_attempt.delete() class Migration(migrations.Migration): dependencies = [ ("axes", "0006_remove_accesslog_trusted"), ] operations = [ migrations.RunPython(deduplicate_attempts), migrations.AlterUniqueTogether( name="accessattempt", unique_together={("username", "ip_address", "user_agent")}, ), ] django-axes-5.39.0/axes/migrations/0008_accessfailurelog.py0000644000175000017500000000422314277437635022463 0ustar jamesjames# Generated by Django 3.2.12 on 2022-03-15 03:00 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ("axes", "0007_alter_accessattempt_unique_together"), ] operations = [ migrations.CreateModel( name="AccessFailureLog", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ( "user_agent", models.CharField( db_index=True, max_length=255, verbose_name="User Agent" ), ), ( "ip_address", models.GenericIPAddressField( db_index=True, null=True, verbose_name="IP Address" ), ), ( "username", models.CharField( db_index=True, max_length=255, null=True, verbose_name="Username", ), ), ( "http_accept", models.CharField(max_length=1025, verbose_name="HTTP Accept"), ), ("path_info", models.CharField(max_length=255, verbose_name="Path")), ( "attempt_time", models.DateTimeField( auto_now_add=True, verbose_name="Attempt Time" ), ), ( "locked_out", models.BooleanField( blank=True, default=False, verbose_name="Access lock out" ), ), ], options={ "verbose_name": "access failure", "verbose_name_plural": "access failures", }, ), ] django-axes-5.39.0/axes/migrations/__init__.py0000644000175000017500000000000014277437635020225 0ustar jamesjamesdjango-axes-5.39.0/axes/models.py0000644000175000017500000000362414277437635015614 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 AccessFailureLog(AccessBase): locked_out = models.BooleanField( _("Access lock out"), null=False, blank=True, default=False ) def __str__(self): locked_out_str = " locked out" if self.locked_out else "" return f"Failed access: user {self.username}{locked_out_str} on {self.attempt_time} from {self.ip_address}" class Meta: verbose_name = _("access failure") verbose_name_plural = _("access failures") class AccessAttempt(AccessBase): get_data = models.TextField(_("GET Data")) 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") unique_together = [["username", "ip_address", "user_agent"]] 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.39.0/axes/signals.py0000644000175000017500000000336614277437635015774 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 Signal from django.dispatch import receiver from axes.handlers.proxy import AxesProxyHandler from axes.models import AccessAttempt log = getLogger(__name__) # This signal provides the following arguments to any listeners: # request - The current Request object. # username - The username of the User who has been locked out. # ip_address - The IP of the user who has been locked out. user_locked_out = Signal() @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.39.0/axes/utils.py0000644000175000017500000000322214277437635015463 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 typing import Optional from django.http import HttpRequest from axes.conf import settings from axes.handlers.proxy import AxesProxyHandler from axes.helpers import get_client_ip_address log = getLogger(__name__) def reset( ip: Optional[str] = None, username: Optional[str] = None, ip_or_username=False ) -> int: """ Reset records that match IP or username, and return the count of removed attempts. This utility method is meant to be used from the CLI or via Python API. """ return AxesProxyHandler.reset_attempts( ip_address=ip, username=username, ip_or_username=ip_or_username ) def reset_request(request: HttpRequest) -> 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. """ ip: Optional[str] = get_client_ip_address(request) username = request.GET.get("username", None) ip_or_username = settings.AXES_LOCK_OUT_BY_USER_OR_IP if settings.AXES_ONLY_USER_FAILURES: ip = None elif not ( settings.AXES_LOCK_OUT_BY_USER_OR_IP or settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP ): username = None if not ip and not username: return 0 # We don't want to reset everything, if there is some wrong request parameter # if settings.AXES_USE_USER_AGENT: # TODO: reset based on user_agent? return reset(ip, username, ip_or_username) django-axes-5.39.0/codecov.yml0000644000175000017500000000032414277437635015156 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.39.0/docs/0000755000175000017500000000000014277437635013742 5ustar jamesjamesdjango-axes-5.39.0/docs/10_changelog.rst0000644000175000017500000000005314277437635016721 0ustar jamesjames.. changelog: .. include:: ../CHANGES.rst django-axes-5.39.0/docs/1_requirements.rst0000644000175000017500000000121014277437635017431 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 `Tox configuration `_ and `Python package definition `_ to check if your Django and Python version are supported. The `GitHub Actions builds `_ test Axes compatibility with the Django master branch for future compatibility as well. django-axes-5.39.0/docs/2_installation.rst0000644000175000017500000001346514277437635017427 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.AxesStandaloneBackend`` to the top of ``AUTHENTICATION_BACKENDS``:: AUTHENTICATION_BACKENDS = [ # AxesStandaloneBackend should be the first backend in the AUTHENTICATION_BACKENDS list. 'axes.backends.AxesStandaloneBackend', # Django ModelBackend is the default authentication backend. 'django.contrib.auth.backends.ModelBackend', ] For backwards compatibility, ``AxesBackend`` can be used in place of ``AxesStandaloneBackend``. The only difference is that ``AxesBackend`` also provides the permissions-checking functionality of Django's ``ModelBackend`` behind the scenes. We recommend using ``AxesStandaloneBackend`` if you have any custom logic to override Django's standard permissions checks. **3.** Add ``axes.middleware.AxesMiddleware`` to your list of ``MIDDLEWARE``:: MIDDLEWARE = [ # 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 generate 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. Disabling atomic requests ------------------------- Django offers atomic database transactions that are tied to HTTP requests and toggled on and off with the ``ATOMIC_REQUESTS`` configuration. When ``ATOMIC_REQUESTS`` is set to ``True`` Django will always either perform all database read and write operations in one successful atomic transaction or in a case of failure roll them back, leaving no trace of the failed request in the database. However, sometimes Axes or another plugin can misbehave or not act correctly with other code, preventing the login mechanisms from working due to e.g. exception being thrown in some part of the code, preventing access attempts being logged to database with Axes or causing similar problems. If new attempts or log objects are not being correctly written to the Axes tables, it is possible to configure Django ``ATOMIC_REQUESTS`` setting to to ``False``:: ATOMIC_REQUESTS = False Please note that atomic requests are usually desirable when writing e.g. RESTful APIs, but sometimes it can be problematic and warrant a disable. Before disabling atomic requests or configuring them please read the relevant Django documentation and make sure you know what you are configuring rather than just toggling the flag on and off for testing. Also note that the cache backend can provide correct functionality with Memcached or Redis caches even with exceptions being thrown in the stack. django-axes-5.39.0/docs/3_usage.rst0000644000175000017500000001032314277437635016021 0ustar jamesjames.. _usage: Usage ===== Once Axes 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. - ``python manage.py axes_reset_logs (age)`` will reset (i.e. delete) AccessLog records that are older than the given age where the default is 30 days. 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.39.0/docs/4_configuration.rst0000644000175000017500000017020414277437635017572 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. +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | Variable | Default | Explanation | +======================================================+==============================================+===========================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================+ | AXES_ENABLED | True | Enable or disable Axes plugin functionality, for example in test runner setup | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_FAILURE_LIMIT | 3 | The integer number of login attempts allowed before a record is created for the failed logins. This can also be a callable or a dotted path to callable that returns an integer and all of the following are valid: ``AXES_FAILURE_LIMIT = 42``, ``AXES_FAILURE_LIMIT = lambda *args: 42``, and ``AXES_FAILURE_LIMIT = 'project.app.get_login_failure_limit'``. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_LOCK_OUT_AT_FAILURE | True | After the number of allowed login attempts are exceeded, should we lock out this IP (and optional user agent)? | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_COOLOFF_TIME | None | If set, defines a period of inactivity after which old failed login attempts will be cleared. Can be set to a Python timedelta object, an integer, a float, a callable, or a string path to a callable which takes no arguments. If an integer or float, will be interpreted as a number of hours: ``AXES_COOLOFF_TIME = 2`` 2 hours, ``AXES_COOLOFF_TIME = 2.0`` 2 hours, 120 minutes, ``AXES_COOLOFF_TIME = 1.7`` 1.7 hours, 102 minutes, 6120 seconds | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_ONLY_ADMIN_SITE | False | If ``True``, lock is only enabled for admin site. Admin site is determined by checking request path against the path of ``"admin:index"`` view. If admin urls are not registered in current urlconf, all requests will not be locked. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_ONLY_USER_FAILURES | False | If ``True``, only lock based on username, and never lock based on IP if attempts exceed the limit. Otherwise utilize the existing IP and user locking logic. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_ENABLE_ADMIN | True | If ``True``, admin views for access attempts and logins are shown in Django admin interface. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP | False | If ``True``, prevent login from IP under a particular username if the attempt limit has been exceeded, otherwise lock out based on IP. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_LOCK_OUT_BY_USER_OR_IP | False | If ``True``, prevent login from if the attempt limit has been exceeded for IP or username. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_USE_USER_AGENT | False | If ``True``, lock out and log based on the IP address and the user agent. This means requests from different user agents but from the same IP are treated differently. This settings has no effect if the ``AXES_ONLY_USER_FAILURES`` setting is active. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_HANDLER | 'axes.handlers.database.AxesDatabaseHandler' | The path to the handler class to use. If set, overrides the default signal handler backend. Default: ``'axes.handlers.database.AxesDatabaseHandler'`` | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_CACHE | 'default' | The name of the cache for Axes to use. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_LOCKOUT_TEMPLATE | None | If set, specifies a template to render when a user is locked out. Template receives ``cooloff_timedelta``, ``cooloff_time``, ``username`` and ``failure_limit`` as context variables. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_LOCKOUT_URL | None | If set, specifies a URL to redirect to on lockout. If both ``AXES_LOCKOUT_TEMPLATE`` and ``AXES_LOCKOUT_URL`` are set, the template will be used. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_VERBOSE | True | If ``True``, you'll see slightly more logging for Axes. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_USERNAME_FORM_FIELD | 'username' | The name of the form field that contains your users usernames. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_USERNAME_CALLABLE | None | A callable or a string path to callable that takes two arguments for user lookups: ``def get_username(request: HttpRequest, credentials: dict) -> str: ...``. This can be any callable such as ``AXES_USERNAME_CALLABLE = lambda request, credentials: 'username'`` or a full Python module path to callable such as ``AXES_USERNAME_CALLABLE = 'example.get_username``. The ``request`` is a HttpRequest like object and the ``credentials`` is a dictionary like object. ``credentials`` are the ones that were passed to Django ``authenticate()`` in the login flow. If no function is supplied, Axes fetches the username from the ``credentials`` or ``request.POST`` dictionaries based on ``AXES_USERNAME_FORM_FIELD``. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_WHITELIST_CALLABLE | None | A callable or a string path to callable that takes two arguments for whitelisting determination and returns True, if user should be whitelisted: ``def is_whitelisted(request: HttpRequest, credentials: dict) -> bool: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_LOCKOUT_CALLABLE | None | A callable or a string path to callable that takes two arguments returns a response. For example: ``def generate_lockout_response(request: HttpRequest, credentials: dict) -> HttpResponse: ...``. This can be any callable similarly to ``AXES_USERNAME_CALLABLE``. If not callable is defined, then the default implementation in ``axes.helpers.get_lockout_response`` is used for determining the correct lockout response that is sent to the requesting client. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_PASSWORD_FORM_FIELD | 'password' | The name of the form or credentials field that contains your users password. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_SENSITIVE_PARAMETERS | [] | Configures POST and GET parameter values (in addition to the value of ``AXES_PASSWORD_FORM_FIELD``) to mask in login attempt logging. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_NEVER_LOCKOUT_GET | False | If ``True``, Axes will never lock out HTTP GET requests. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_NEVER_LOCKOUT_WHITELIST | False | If ``True``, users can always login from whitelisted IP addresses. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_IP_BLACKLIST | None | An iterable of IPs to be blacklisted. Takes precedence over whitelists. For example: ``AXES_IP_BLACKLIST = ['0.0.0.0']``. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_IP_WHITELIST | None | An iterable of IPs to be whitelisted. For example: ``AXES_IP_WHITELIST = ['0.0.0.0']``. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_DISABLE_ACCESS_LOG | False | If ``True``, disable writing login and logout access logs to database, so the admin interface will not have user login trail for successful user authentication. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_ENABLE_ACCESS_FAILURE_LOG | False | If ``True``, enable writing login failure logs to database, so you will have every user login trail for unsuccessful user authentication. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT | 1000 | Sets the number of failures to trail for each user. When the access failure log reach this number of records, an automatic removal is ran. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_RESET_ON_SUCCESS | False | If ``True``, a successful login will reset the number of failed logins. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_ALLOWED_CORS_ORIGINS | "*" | Configures lockout response CORS headers for XHR requests. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_HTTP_RESPONSE_CODE | 403 | Sets the http response code returned when ``AXES_FAILURE_LIMIT`` is reached. For example: ``AXES_HTTP_RESPONSE_CODE = 429`` | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT | True | If ``True``, a failed login attempt during lockout will reset the cool off period. | +------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ 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', )`` .. note:: For reverse proxies or e.g. Heroku, you might also want to fetch IP addresses from a HTTP header such as ``X-Forwarded-For``. To configure this, you can fetch IPs through the ``HTTP_X_FORWARDED_FOR`` key from the ``request.META`` property which contains all the HTTP headers in Django: .. code-block:: python # refer to the Django request and response objects documentation AXES_META_PRECEDENCE_ORDER = [ 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR', ] Please note that proxies have different behaviours with the HTTP headers. Make sure that your proxy either strips the incoming value or otherwise makes sure of the validity of the header that is used because **any header values used in application configuration must be secure and trusted**. Otherwise the client can spoof IP addresses by just setting the header in their request and circumvent the IP address monitoring. Normal proxy server behaviours include overriding and appending the header value depending on the platform. Different platforms and gateway services utilize different headers, please refer to your deployment target documentation for up-to-date information on correct configuration. 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 separate process's 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.39.0/docs/5_customization.rst0000644000175000017500000001343514277437635017636 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. Customizing lockout responses ----------------------------- Axes can be configured with ``AXES_LOCKOUT_CALLABLE`` to return a custom lockout response when using the plugin with e.g. DRF (Django REST Framework) or other third party libraries which require specialized formats such as JSON or XML response formats or customized response status codes. An example of usage could be e.g. a custom view for processing lockouts. ``example/views.py``:: from django.http import JsonResponse def lockout(request, credentials, *args, **kwargs): return JsonResponse({"status": "Locked out due to too many login failures"}, status=403) ``settings.py``:: AXES_LOCKOUT_CALLABLE = "example.views.lockout" django-axes-5.39.0/docs/6_integration.rst0000644000175000017500000002217014277437635017246 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 configuration, and **Incompatible** means that a component has been reported as non-functional with Axes. ======================= ============= ============ ============ ============== Project Version Compatible Functional Incompatible ======================= ============= ============ ============ ============== Django REST Framework |check| Django Allauth |check| Django Simple Captcha |check| Django OAuth Toolkit |check| Django Reversion |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 path('accounts/login/', LoginView.as_view(form_class=AxesLoginForm), name='account_login'), path('accounts/', include('allauth.urls')), ] Integration with Django REST Framework -------------------------------------- .. warning:: The following guide only covers authentication schemes that rely on Django's ``authenticate()`` function. Other schemes (e.g. ``TokenAuthentication``) are currently not supported. Django Axes requires REST Framework to be connected via lockout signals for correct functionality. You can use the following snippet in your project signals such as ``example/signals.py``:: from django.dispatch import receiver from axes.signals import user_locked_out from rest_framework.exceptions import PermissionDenied @receiver(user_locked_out) def raise_permission_denied(*args, **kwargs): raise PermissionDenied("Too many failed login attempts") And then configure your application to load it in ``examples/apps.py``:: from django import apps class AppConfig(apps.AppConfig): name = "example" def ready(self): from example import signals # noqa Please check the Django signals documentation for more information: https://docs.djangoproject.com/en/3.2/topics/signals/ When a user login fails a signal is emitted and PermissionDenied raises a HTTP 403 reply which interrupts the login process. This functionality was handled in the middleware for a time, but that resulted in extra database requests being made for each and every web request, and was migrated to signals. 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 axes.utils import reset_request from django.http.response import HttpResponseRedirect from django.shortcuts import render from django.urls import reverse_lazy from .forms import AxesCaptchaForm def locked_out(request): if request.POST: form = AxesCaptchaForm(request.POST) if form.is_valid(): reset_request(request) return HttpResponseRedirect(reverse_lazy('auth_login')) else: form = AxesCaptchaForm() return render(request, 'accounts/captcha.html', {'form': form}) ``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 from axes.helpers import get_client_ip_address, get_client_user_agent class AxesOAuth2Validator(OAuth2Validator): def validate_user(self, username, password, client, request, *args, **kwargs): """ Check username and password correspond to a valid and active User Set defaults for necessary request object attributes for Axes compatibility. The ``request`` argument is not a Django ``HttpRequest`` object. """ _request = request if request and not isinstance(request, HttpRequest): request = HttpRequest() request.uri = _request.uri request.method = request.http_method = _request.http_method request.META = request.headers = _request.headers request._params = _request._params request.decoded_body = _request.decoded_body request.axes_ip_address = get_client_ip_address(request) request.axes_user_agent = get_client_user_agent(request) body = QueryDict(str(_request.body), mutable=True) if request.method == 'GET': request.GET = body elif request.method == 'POST': request.POST = body user = authenticate(request=request, username=username, password=password) if user is not None and user.is_active: request = _request request.user = user return True return False ``settings.py``:: OAUTH2_PROVIDER = { 'OAUTH2_VALIDATOR_CLASS': 'example.validators.AxesOAuth2Validator', 'SCOPES': {'read': 'Read scope', 'write': 'Write scope'}, } Integration with Django Reversion --------------------------------- Django Reversion is not designed to work with Axes, but some users have reported that they have configured a workaround with a monkeypatch function that functions correctly. ``example/monkeypatch.py``:: from django.urls import resolve from reversion import views def _request_creates_revision(request): view_name = resolve(request.path_info).url_name if view_name and view_name.endswith('login'): return False return request.method not in ["OPTIONS", "GET", "HEAD"] views._request_creates_revision = _request_creates_revision django-axes-5.39.0/docs/7_architecture.rst0000644000175000017500000000701414277437635017406 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.39.0/docs/8_reference.rst0000644000175000017500000000062614277437635016665 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.39.0/docs/9_development.rst0000644000175000017500000000257014277437635017252 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 GitHub Actions, 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 py39-django32 After you have pushed your changes, open a pull request on GitHub for getting your code upstreamed. django-axes-5.39.0/docs/Makefile0000644000175000017500000001640114277437635015404 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.39.0/docs/conf.py0000644000175000017500000000771314277437635015251 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 sphinx_rtd_theme from pkg_resources import get_distribution import django from django.conf import settings settings.configure(INSTALLED_APPS=["django", "django.contrib.auth", "axes"], DEBUG=True) 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 full version, including alpha/beta/rc tags. release = get_distribution("django-axes").version # The short X.Y version. version = ".".join(release.split(".")[:2]) # 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.39.0/docs/images/0000755000175000017500000000000014277437635015207 5ustar jamesjamesdjango-axes-5.39.0/docs/images/flow.png0000644000175000017500000040364514277437635016700 0ustar jamesjames‰PNG  IHDR5ˆ{Ņ }zTXtmxGraphModelMWĮŽėēŽũš tîs:–ŗË9L.œsÎūúgŸũh JĨiY$Š˜éÎŧjŗ膴ĘĢ,ũŲ‚üĀū ~` ŸAū >âGŋ–õW÷8Ž?éĒáWY˙_™:ÜUÛF?ūŅ˙¨QRõ바?0ũüû5kŸßgųuëüį ˙‚ČŋČ˙>SjÛĖËbšZß]`üŒŊɂ­*?ķĖÛĒy-ødI3ŧ0åCŖ wgˆ'äŧ•ˇ‚ņ¨=‹0Hwc-‡Š5T9˜o)OF•L¤ņQ% rāŅ(ß *ƒĐ}"÷g‹u[õy=ŗ–‡wâŊ"]ËįĻ›ÂZQ¤—\\,2 98ĄÜ1œ¸Ŗãûr‘Ĩž'ã Øsf~fģ“°FüÃÔ#FŸĨ‚?ĘJĩ“sŪ °ˇÎO’•LēŖKĐâģ#œĮTāä°Ģ\@|j+EĻf p°ĖQȖ†ÉJúØq?Ą!ˆUÎŽ…Æ0Ī`Šč#:ũR*IęPøĮÚú>ÃÕÉâņ7 ×Ŗs9ˆ)ŧRZJP?ūš§eøy”ŠJ9īŦKžäMĨœ¤üČQēUûƒvž˙ž÷¤On7iÄ랴K 4ũ~‘VЌדŠR¨­VÄKig9đŽ‰ |§aŋXᜯš•ō—?hæT‹›•æą 7rĻv}­°q›ƒ1–îXšOfÜáÂY^(āē‹˜Q<æņGUbŧE'ÍMĖ™ũĸAãrü+Šû$5ĪéæVÚá õxSƒŋdņ™b™,ׯXccŠ™ ąššz+ãJ>Ĩ‚ÅhéŠÕ5–û>ŲĀ_ķkēéJå5kŅÔęlYÄé§U<ĸŧŦ{īD(ĐΌž@C¤/ 㨖ŗã2×@î!Īii‰WæCĒ2ļđA¨ ]*ƒ.ˆÜânAˆ~>1@Sö0Ô! ¯†UÄųŨĖš)O,ôwđĩX¸áׂr§~Ũ’ŋø‚`į÷ŽīûzœÂ‚ĨtQ=h“1Ķ0TŖ‹!gⱙŸäėuČÆ}Ė`ĩONÂИa,ö8Ž šŲ8 ¯ŋ­ÅHŌĻûÎŊŪë)N2)Ây*-›Ī6ũÂt=hųŊĻOeažŦõ˛H9‚ģp/MŽ´œeƒöŊũ\â2€oļFÖßč¨:Šf@€1VŸFļXwpžŲēQō˛F9Áų”%ZE Æ8hjŖ!) úāŨÍᲟšũæûØTqÁž “~y6÷ĄmØÕę˛ëķŲyĒfÛÃŪđ{Ôj9\š6õąN÷ `Ģ&AŦą"¸DÆŖmS ^S’=ŠÅšK‰ HpÖ-•*ĸąĄ‰ĩ:Á”× œ‡ëGa^:\WÔkâ A—šžΏđã‹D…Z!TQį¤á>¨’9֛’ĩÍ' ™Žąx/h"˙hĄÖëÉí3v }@a zŗĮkÖĢ­Ãĩ4všĪŖ,õ-7Ĩ¸ŠSņ0ôĢ–š|ž N÷ÄdŒÆYđœjŒÖŸ÷&âHNØnMÉdúnø[RēZ‘tĶ,.""ÂÂ7+nV‰#_6§ŋâŪčĖÖM°ĩû ÅÄßDOqq pHf0Ō„ƉšÛq ĶKöKąK Â–I]°āāKBĀtbž7pVrË97žˆWŧ0Đé{Ņ+o2ũë+÷JįąZQ^ė™Ųđ¤ŨŗuüagÃƁ.ĮCX+ ÚnÖo‰Đvõ’Å­”u@˛—Ü—:ÚFŠšÉŅrvs…ÚŖ’}ŸŧĖ“<{(ák‡†h.É­VcfíD4ųGû֌äâlâ§æiŒA¨ŌęĶ(§=ˡ(N†0l°[D”ÉĐ“°úio}RˇÆo€ØäGÉ-†ÉlĪqđÍŊũĶ—éä`QûĀ–Ļ܄ę:Ĩucâô“=Ōōcų3­Jë=$ÂĶ|nļ´-yåŒxÜ"öéVų‚ Ô<ŅÄHÚ8‘M¯e$ h&|–ĶTķEËô H;!qŪňgĢ•zÉëėpÕ l¨`Şæc2‚ļf%weVčY×C*$ĪüVŸÅĩúiXįd{Ũˇ“€ %Öcô„×ĻÂz 2¯ļģŌE7đŲ?6Ô?™ikÛĖöŒ¸LĮ|¨ Ûv`pz8—&Ëõʋ´ÕĮŗtE ß2Ļ˘&™…ö ¤ęŊ $øT ]M²NĸŪ6Äøú2pˇ< %ŖŖl3-<ˇuårOz¤J’ū*&1úēŋ­–qpÕúî~§T˙a„ū-cĒ•kĶíK6Æhą74B& ~‹IŽÔßZ`iP[WĐA͟NcÂÄ'*āwŒ{xÍĖ8ĘŌUŠëc+M8Ëîķ¨ 5&ˆšø6WĖ߂`šŠĸŊö, 2ÎQâąÖčŧ:ŠĩwҞ#;4Î%Ų’#ķüÍä#Õę…ybĒū˜M;ߋUúōrĖÖî^­î¸ä {cĪû ôėîŋ|i‹ØdéÜÚW˜é#9rëę|K) pdį§}Ķ\Ú:î˜ũũŒmQÂ-“ŠÍIͧ_ Ûō $/b>ü=orBLšÍ˛Ę˛Hŗž¨•á¤r;xĄŦÎ RWōa Dę…=?œ9ē‘,ĮaŨĮ nöœ Yō jÚ§OŸĀLúš:øÁ‡`ÅxĒĪÛ}F?K_û^›ûčQÄÃā:Ņąûd}f]Q!Š÷ĩ‘ÛãäģŨŖŠņé BŠPd}Õ,ŖD´Ã2!Cˇ:É]žŨ#Ûúė›}Ēe…Î Žfā|W΁U$¯hY34ķSbÃ(:úĨ!*$¸2pžˆ&õUŗąXāōšÉĮĻūĒsáÉēyœûŪ °Ī‹! 5ĘėÍE.ę&kã[ՌáãģOcáŦÆ…caiļæN|{kOo¤ŲúĐ-Đöi­u™0›ŽĄf ,%7S2N]Zãeš]KBq räNã°á‰|Č9í{sx ä-2FFÜãō/ æéHûä…$#—ĄT,´6׀7äEŽĐĸęëxl !g͊4Ã5ū {ŽßÖ˄(͊ãn†ė‹üt hËŲY2_›55æk§ŖØĄJ‘/1x´!ĘôAīŒež„ Y2°PøųhļīÅ!õvēÍߘĮ#žŸ~*­ouPīU~˛–˙ŋûáßËâúß{8ĖũBø^ IDATx^ė´ÅöõK%0 bœ0Åđ•‡˜PŅgÆđP@Ô§ ˆb€sF|&ĖbDÁ§¨sV0 fŋõ+˙uŋĻé™îžéžéžŲĩÖ]æöTWí:uęėsNUÍ÷×_ũeT„€B@! „€9E`>‘šœŽœš-„€B@! „€E@¤F‚ „€B@! „@ŽŠÉõđŠņB@! „€B@d–ÔüöÛoæ?ū076 ,°€FJ¤Šō6ß|ķ™FĨúWųīŋ˙nūüķOͤI“Šŧ¯–^ōÉ'Ÿ˜-Zä;{:’øë¯ŋšųįŸŋbķ7^TŽVļŌūōË/ĻiĶĻVīÕJÁfāGzĩVF´:ũ¨´=e>2_ąOd—‰Š’šĢ¯žÚôéĶgž­ˇŪzĻGæ¸ãŽŗJ–Ōž}{ķÄO˜áǛ˙ûßՑėßzöŲg›ÚoŸyæ™fĐ A%Ö¤¯…!€ōyūųįÍÚk¯m–[nš°Į ū}ˇŨv3͚53wÜqGÉuûâW_}eŪxã Ķļm[ŗā‚ šŖŽ:Ę<ôĐCfúôéŠŧJũīŧķÎ;ÍžûîkŪ˙}ŗĘ*Ģ”ũŪ—^zÉ,ļØbûO?ũÔŦ°Â æĻ›n2x`ŲuU@ģŅ īŊ÷žyîšįĖĨ—^jŪyį3qâÄTŪWJĨ~Ū}÷]ķõ×_›-ˇÜŌVW‰qĶnûâ|ˇšĪúÛŊÆk˜Ž;šË.ģ,ĩfųßyĖ1Į˜{îšĮ|ôŅGeŋ͝Į’žĢūNž<ŲlļŲfvîlžųæeˇ?¨‚•VZÉzčĄæôĶOOĨū JĪ=÷\sÖYg™īŋ˙žėw&9žQ3sæLŗä’Kšë¯ŋŪ|đÁQž’ú3iËaę(ņIÛŦ‘íÚĩ3W^ye`‹^xáŗÕV[™W_}Õl¸á†Īā|@ŋqÄfã76›nēŠ=zt‰=ŦŨ¯U”ÔŒ5ĘHĄrČ!‡4 R^I Œ{Í5×´†eÅW4~øaMyÃ˛4>˙üsŗüō˛n¸ÁtĐA%7-i%æoȘ1cĖŪ{īmIĖę̝^ãÖ˙ÎĮܜzęŠæîģīļ˜•[PÔ˙øĮ?Ė5×\c ÷=öØÃ0ģė˛KšU~ß9 3p€Å0k¤ÆÃąĮknŋũvķÅ_Ø>eÔøÛ—ĘĀĨPŠŋŨ• 5ūwžwŪyÖĄÂ|*ˇøõXŌsÕßž—_~Ų´iĶ&URŗė˛ËÚõūŒ3Î(žČßO’Ô$9žQ:đíˇßšĨ–ZĘÚ@ØBY(iËaúԆ¤íũöÛĪl˛É&Ļ˙ū"5)zUHÍĸ‹.jnŊõVÛ5Œ˙ /ŧ°ŧųæ›fuÖ1“&M2LōĩÖZËŦļÚj)ÃP¸zR„HkˆZđ|á÷– &4xjŖÖŖįĸ! RS'?Љ†hô§ŧ¤&úˇJãčüķĪođÂĘŠņ÷N¤Ļôņ.öÍ,š${–”‹Ú&‘š¨HUîš,’šĘõ>[oJšÔ„õN‘š0„ĸ˙Ŋ*¤f™e–iđ\ŌTŌJqÁ“Løø´ĶNŗĄ8°{íĩ—!-áP RÖļŪzkëųlŲ˛Ĩõ˜ <ØÖCXŽî7ß|cŋ{Øa‡BáGŋ~ũĖ“O>iĶ€†jnžųfķŨwßŲTˇ6ÚČ>wã7š‘#GRlh/ĄÁsÎ9ĮĻÚ+'œp‚šā‚ čōÃ?˜ž}ûÚĪ(Ī<ķŒ}'…đöŽģîjŽģî:›öy"›~vŌI'YĪÜ|`ÃĶ—_~šmGšØ<üđÃķŧsá…ļũæ}xQ‰*Đ.Rh!ƉqKJ1y÷ącĮZL‰Ė…íŪŊģíÃāŋ˙ũ¯•Y7>Ŋ{÷ļ'ߝųĮ˜đw~wdƒöų´…šČüüøãíœAųž—ԐŪGũĪ>ûŦ•O"GČ/ķDŸ!EpĻČ"ú‚+ĪīšįžV_xKœīs8āˏøâ‹­Ų=)ëŽģŽ%Šė1”(x!',.Ž,Dļô#(ˆÔ k,Đ´IΆ= u"ˇ´“rC!Í 9Á@lÕĒ•í÷UW]e1į=Ģũë_†ÍīôÉaY.6(I˙;‘+đ_ 6dŒũŦŲŗg[9€üŖhÃä…Ŋ3:deĖŧAn#Œz7QĐP”8¤™ī8įmĄ0~žŽ€¸T-¯ ;gF!ÄQv¤†O”?cĸģEū Kė×c.b\2‘Eŧcŧ“ą|íĩ×ŦCƒúũésČ]ÔīR‡ÃßˇW^yÅ´Čī woŋŅ)ÅÆ‰yÃķîÍbdĪ˜#“ ŪËŧ z‡L Įđķˇtôh”qCāÔ@n.fLysb”æ&[aē9öˇģuëÖv˛`Ôą†`|ēTābz9îôķ“ųƒáČįĶĻMŗ} K#Ė!į˜cüŅ“ČsÜ9´ø.ķŒš˛Å[ØųŽ’Âé×cė‡ssƒīĄ§Đeč|œ@|ŸÚëtzc‰ņŊå–[ŦĄ÷Gjø= nŦąô×íŗ §°>;RCßY™Ė#H@šãŠpû=XŅũČ:Ž 0ˆBjœŪĻ˙8#0æXĶ ˆŒ˜ēņõÎCô}wdÖÉXąĩĒW¯^Ą:5ŒÔ`Dƒ!…ĩÜvld‚u„õįō‚ū#tũ„(Ą§ŅČ&s=ā0fÍŪa^yå•m;0p‘AŒĖ“bmÁ‘‡ĶˆÂšˆ,Cŧ é'ę +akŗÛãH=Aķ9Šk8L¨3ĒD6p"Cq8a; §ŧ]ņĀØŽĸÛĐSč|dšÆųÎŧĄ‘š°š„>Šē~ĄŠŲ:´ļ9šu=1.&Gaã˜äß3Cj`“(a&'F•ŸÔ œY¨Q6xäYH™PV&Ŗ#5xž™¤ŧ[">ø`C$ƒˆŪ"&“•‚qĀĸ1qˆĸ@‰đƒÍëõ^(H…¨†˛#xūvŪygû7ꡟüŨŒ1”* Ū‰‚AA3ųxžļ2Aœ7 o0ßÅXÆc yÚ[‚‡ˆ6 €gũõסÆ> k˜!ƈG‚Œ&ōDũ¤åF{‰`āųucĘwXØYäč íŸÄ˃aK@q˛•#xôCã•É )„ņN<ļū´ :u%† …ūaĶ Ųģ‹öōy!RƒŌÁP@Ąģ ö."Á #5(ō =5((—nI;YXy„Ī…ŖËÁÆ˙Nī…҉҄qäŧŊ,Ō¤õĐŧjÅä…özĶĪÜâáH ƒ@2ÆNŪ ĐĖGž%šÂû!znCvX$ xȍŋK?s„™sF¸q§Č9æ%mC†XTX8ņŧ!Kôũ§Ÿ~˛Ū^oaÁ‰ú]6ûzÉ]PúYąq÷÷ŲYĄqb>#5NN3ãŒų‡^b‘õˇ/긥1Ԝ=`.˛ ŌęöęHHĀ å…Îä$vęÔ)ŠÁÛÍØ`:L1f—BĮû1jŨ‚‚łĖĸÉDĄāeĮS‡ņ‰ŅËv^RžactA<™ôDSŧŪF Q>w"īA.- O•+,ü`†áŠ×ŦŧF"ĪAØ X…ŌĪH!cĖĀ ¯8rH)‡Ôxąĸ.æIÔqķ’q ŅbŒ †5˙ķö­]TˆÔ` 8BÂģX|‘Qæt˜^āīč "ÁDē(Ėyˆˆ3$Š‘tķ–5#¨`pC˛Áũ‹!Šs‹ųÁ<)Fj˜#8˘?č.WøŪxäųoo”“lÚdā;Rƒķěî‰n… ¸‹SXŸŨ:F{Y7é‡s”;DãX1ņģB$ˆMRæČk-k NGpvxxI kz„ƒ„š-á%5ÅÖ*žS ΍¤ÆE›Y{°W‡œ+Ė?ė!Ö*ô[!štNIڌūt…īRõ{I k§W>ЕØ#ŧįfX[wH QA ĶO“É÷aąĩŲéãBķšu?Ž=ā0‰ŖŊ¤™C_:”î ˇp˜ŗöą>8įŸ‹t˙ R6—ĐgQ×/§÷ Ų:N¯âÜuéûQä(Ę8&õL&H “މ‰ŅîXŋŸÔ¸4%:Žw…éBr^RãŨ¯ õŒÂ3„k‰2¸”5>gpž]H Ŧãâ˜(˙ưĀĀđīrá ‚bÃ3x6(Ū¨˙'ŧ‹ĐRH™ ĻķēšzQw úī?"Đë=đļő úļԋĮ…I](R‹g‘č ØāōŠņ‚ĮĐĨŸy)v¤Æ{,ˇ—Ô@^Ā2ˆņā-ē¤ũj„Ģ RƒE[Ŋ¤ÆÕK; ‘,Ō™HûpÅK”ücčŒ4dŅ%Ŋ[ˆÔĀČ"âV(Rã?ŌcĘ|pōV6ÅH cž ĸ ;ÂäŒ ‘ÆŦœÚá‰ŅÅâ öZæ´7Ũ/(cT ŠANidŲ)dŪã)Į[…qI4–…•ņsōĪߊ:õģQHMąq÷ËFIąq "5`ˆgÖ<DæŠKÉâČ'ķČk¨ķRƍņÆX%N #5a˛5öęĸB¤Æ¤ŗ—Ô„éæ4¤Æ›BĘ<$Ú…Ô č$Ō÷‚ )6Ô Ąwˆ5s! ŠaŦ0âũē×E'1rÉ`đËQuœ#ÅH7ĸMģ!*Öΰq ëŗ#5Ė=Ö]H–ÛOZîx€#Ynn;ˉŌ3_ĸžCš2Ųč ô%Åé-/ŠažāÃ>pûcü$$l­ Ã3 Šan8âʸsvÎIÁaˆ-SH.I}'m§‡?Ũ( ˛‰ÎöĻAēlqėá„Cˇ@Ŗ´ÂĪZčOá/ϟŠŲS^]Shmvú¸ĐŧƉ×p¤Æģv…é@/ŠaÍaĖŧēÂëĐ#Š…į]›ŨqßA¤&l.áȊē~…Ų:`î?N:ŠUō.ŦĒ“Ø*Šã‚ąˆé%5vnŗ#ʇ “Ť@ 0Öb¤ĨåÎĮc†ˇ€ôw Æ0Âį Õ Å†pAŽ0„Üž˙dsšë|ŽĀ:Ňa‡ŠâŌ ˇ/ˆŋQ?^ Œm hĮ†i;JīĄÔč/lÅÁ ڏׅÉ_X` o<ëNsŖŸ(˜ēö{ŊLvRh/!~đr‹0õ;O¸7ũ,ŠĄŊ,ö^.Ņ- zڅĮ?hĄ(Šqûŧųük,H„ ‘0bquûĻ\*HĄô3"÷†đ1&ņū@jPn`éMĨré‘Ĩ’7å`SŒÔāeÃĶë5jH ]H “—b¤œ‘c gčōŧķ,{ĶĪ’"5(mīBā"CCgT0?ņ^1ū3ŧXx ĨŊŅö¨ßMšÔā‰/6NuxųŧŅQŒsÆRƒĮâtŅVō Ŋ¤ÂWʸ…-čIëĸRHM˜^ ĸį'5ŧ¤)F!5NūđĀēģÖHŅAßB–Ų_įö9€‡KYBjĐO!Ö<ôŊ+^oĐQáQ"5Žoԉ# ‡éŨČ{ؚQŦĪ:|ŸČ Ö] rGĻËÚH€Ëėp˜°>⌌BjXC‰Tšô;tûŪØ‹…ÉÜpé…8?×~Ūį Qo¤ÆOŦŊkUžqI m`}ÄÖđzũY˙YĶ9Ԁõ•vÉ%zƒ,´ %k;zĮŠqmÄfcĻÛ#Ĩ-Ŧ÷~RϟÂHMØÚDjŧķš9×(—ԐĻG4Ė;&Ž8ôX3™ģŪĩ;{(ˆÔ„Í%œxQׯ0<ƒHËv*&Gaã˜äßĢBj uĀ›ßë%5(HQŌ˛$ˇ'…đ#Š(ŒÔđ^ŧ;^2ám‹;(ĀŦü(ž÷oú•÷ģNÁ{÷đw—įË÷I˙ ×… !`(r ŒK C Ć(ũŖ@ŦhËŠ…ā!´,ˆäšģģŪö'䊴) qBö($&4ĘĪ õPž5c<=ÎbąƒÔé€t L‰áu/•ÔāŨÅĀÄ €dām| TŨa¤Æ)(<Ú(q0 }ŦņTōwö ,č#!Ɔ=‹JŖŠÁpÄØ§M´—gYđ(Œ+íĮŖK¨#|?äĶ‘…€!ôĩ˜Įžv—‹˙´ßyŨˆÎ0§ˆÜ1ž R:0†Ãä…üm—˛€ėŖw/ 29ÃS„8rSlŒ°[  ØĖcô(ë?iúŦī~RÃ;]ԑ#“.Ë"Ŧ-î o¤&L?á(d elƒ.×[›Ãär×(—Ô¸Œô6Į¸‰Ū@W]#‚‰aíAE "5aē-Qׯ0<Š+čâĪ09J’´„ÕUQRÊ"đ ŖËeæīūô3&3JÆŌæY„ oÆĸ7RÃÂāRŒ bxČX8$ rwãącÆ<‹ŅŠę Š’ æNˇōöÁE,øĖģąÛ=ã 'ū‘p¸ ^…˛'5(JÛû~đsĮ¤ē_o; G$ˇĮû7›ŅXP)`…ĄD”Š÷°Ø‚¯KéÃ(ÂøĄôc—Md, Ž`ą`°2VL6ęÁËāNbrég^#2ŨĻb&$‹Ŋk8ŖŨæqŒ%Ō(\Á˜†œ˛°SÜ&;ˇųßÚæž‡hbčAā(ėw‚ŧš RY¨ &ŧÛÕéŎČ‹^= õ1ό›ÛŸå}'2ˆ÷cÅåÁãųSúIÚäԋõúS'ĘÅÆ˙NH$† 4Q>N7‚h¸9…L`€y˜ŧ`øē}?ôŌ‚Qč6cP@°]JũÃčD>Y¸ŨÂāŽ8Rãæ€_O¸ ŒÎ ëŋ§ÆīžĮü&ĩÄĶÎįxrY\^ē{§?%HqFųŽ#!¯bū0ĪÂÆŨ˙îbãÄ“nædĀĸˆáЁŧ3?Ŋ'>"īÔÉsūöĄ;âŽõpQ9ĸŊĖw‚’ŋ?a˛EųÛ ~=vsē˜^pãæĸé´ŲĨŸačŖŗƒÆã×íąt)r|×EĮĐ}¤3W 0nŽąæ ‡ĖIôę1úãĢxâ™Ģ|Įô Ö>ŋîå™bégD&Á‡˛ęŠwīAØ8ĄŠõ™č5zÜ­¯.­Đíą,w6ë%Ž/§§ŨšÁß]ęŧŸpX[ņ÷nĶOÎvķ û ÃÖfœĐŦKÅæu\{ hí ́ČŠ~āŠĶˆŗÕ;—‰|šĩĪEfÜßŅ?Č5ä“û‰E˜Íuí Ã=DjÂä(h=MëŗŠ’š8Ā & ā5ˆ™4îd­ }aõŗpq˛ÉÁ(g‘§xŊüĨ‰§ŠÅ߅yÃŪ‘äßņaœfgqæˇˇ°čĸlđđå:1Ęûø˙N›Ũ1ŗū“˙ĸŒSą>‡ĩĨÜņp{Y qú×HĸūŦq…Žh }č'~Ā“qķcāÖEŽĮ8Å!ŠAĪ08ŧŽ“b}Ž‚gfAg]ÁVaūģƒWŧĪ#0¤˙č$ôtX[üí/ϟš;Qpʸ9Ô˙°ĩ9 ĶR끰:ÃūŽ>fū˛y˜DֱׂĸTūú˝KŪúJÁ3i9 ïĐß3Ij0’ņ2QÜšR;čũžķNņ9åŗ.U˟6–ÄûjŠwN?Ūĸ2xTņÂų7°ÖRŋÕ! „€Č–DËÃ.Íëģ rDÚûoH]"ú‚_Š“5ėúûßW@pā ŅR!L’wšĄ`ŧIy¤„=ã.õˆĪđ˛äáˆbŊ<ã.d* IDATOƒĄß¤U”ĨŠėÔO! „€¨DļˆŽ•ųjĨ˙Rnĸ5¤b‘îĨ’nĪĒ7˛œÎ›Tk­ IRCč‹Č›ĨܙđIû'łh˙8á¤ßUKõ>'$ĘbA¸ß]øUK}T_„€B@ø BÃƤoĨ 1! Ē‹@&IMu!Ņۅ€B@! „€Č"5y-ĩU! „€B@yŠ‘P! „€B@!kDjr=|jŧB@! „€"5’! „€B@! r€HMއOB@! „€B@¤F2 „€B@! „@ŽŠÉõđŠņB@! „€B@ˆÔH„€B@! „€Č5"5š>5^! „€B@‘ɀB@! „€šF@¤&×Ã§Æ ! „€B@! R#B@! „€B ׈ÔäzøÔx! „€B@! Dj$B@! „€B@䑚\Ÿ/„€B@! „€Hd@! „€B@\# R“ëáSㅀB@! „€Š‘ ! „€B@!kDjr=|jŧB@! „€"5’! „€B@! r€HMއOB@! „€B@¤F2 „€B@! „@ŽŠÉõđŠņB@! „€B@ˆÔH„€B@! „€Č5"5š>5^! „€B@‘ɀB@! „€šF@¤&×Ã§Æ ! „€B@! R#B@! „€B ׈ÔäzøÔx! „€B@! Dj$B@¤†ĀwŪiš6mšZũĒXü 0ß|ķ™=öØ#? VK…€Č"5š.5Vä ˙ū÷ŋĻC‡ųj´Z+„@*<účŖĻsįΊԭJ…€"5’! RC@¤&5hUąČ"5š25Xä ‘š\ —+ō…€HMžÆK­i" R“&ēĒ[‘ɀŠ! R“´ĒXä‘šÜ ™,r…€HMކKųB@¤&_ãĨÖ 4ŠI]Õ-„€Hd@ÔŠI ZU,r‡€HMî†L šB@¤&WÃĨÆ |! R“¯ņRk…@šˆÔ¤‰ŽęB@¤F2 „@jˆÔ¤­*šC@¤&wCĻ \! R“ĢáRc…@žŠÉ×xŠĩB MDjŌDWu !P6Š9r¤™ļiĶÆüūûī%ÁR#ûĨ¤áĶ—„@*$iŋ”DjN:é$ķÎ;ī˜1cÆX%§"„@v ĸą×^{YgQŽj–RHÍ9įœc6ŪxcÛ! ˛‡¤æĐC5—]vYŦÆeÔČ~‰5dzXTrí—Ø¤…0˙üķ›áÇW´Ŗz™ņ8ņÄ­Ķ’P­—ÔpĀfģíļ3'œpBĩšŦ÷ !͏?ūhŽ>účO˙ũHĩIė—ČCĨ…@U(Õ~‰EjŲ^sÍ5CEEė#@ ÚQGevÛmˇĒ46ŠųķĪ?͂ .¨čoUFJ/ņ¸ôŌKÍRK-eöÜsĪH_Ž&Š‘ũiˆôČ ĨØ/ąHMãÆÍœ9sdtdfČÕ!PŸūŲ4oŪÜÎÛj”8¤†¨R“&MĒŅLŊS ]$ę›j’Ų/%°ž&Ē„@)öKdRÃæã÷ßߌ1ĸJŨĶk…€(ž}ûšV­Z™#<˛”¯—õ¨¤fÚ´if‘Eą',Š!ūúë¯ČN“j‘Ų/ų‘'ĩTxˆkŋD&5={ö4íÚĩ3‡rˆB G2:aÂsõÕWWŧÕQIMĮŽíqąč! ōƒĀ‡~h|đAsđÁ‡6ēZ¤FöKčĐč!IâÚ/‘I ÷_Œ=ZĮ6grØÕ(!PŽzîŨģˇŊOĒŌ%*Šá¤ļI“&ŲT9! ōƒĀ÷ßoöŲg3vėØĐFW‹ÔČ~ = 2‰@\û%2ŠŲa‡Ė“O>™ÉNĢQB@G Zķ7*Šéßŋŋščĸ‹4ŒB@ä"ŦãÆ myĩHMĩô_( z@PâĖßȤfžųæ3äÎĒ!?Ē5Ŗ’6ņōŖ"„@ūāŠ‡(G;W‹ÔTK˙åo$Õb!=âĖ_‘šėŸZ$G ŽRHōå"5IĸŠē„@6øíˇß ?aE¤& !ũ]?qė‘ɏ¨â(…$áˆJjnģí6ĶĢW¯$_­ē„€¨"5Z¯uˆ@ûE¤ĻD]Ž?â(…$҉Jj8ÎYé­I"¯ē„@åāöīŗÎ:+ô…ŠÔ„B¤„€đ!Į~Š‘ø:@ ŽRH‘š$ŅT]B › _fĪžÚ8‘šPˆô€"5’! Š!uRsÄG˜ë¯ŋ^ƒ(„@ŠÉá ŠÉB 'Äą_ŠÉÉ Ē™B â(…rŪã˙nÔHN?KuÕ%*‹€N?Ģ,Ūz›¨'âØ/"5õ$ękŨ"G)$ ’HM’hĒ.!MtP@6ĮE­ĩ€@ûE¤ĻF\}!ÄQ I‚•Ôčôŗ$QW]B ˛ˆÔToŊMÔqė—š'5?ūøŖųâ‹/ĖkŦ‘køķĪ? ?5Ęu?Ôøę G)$Ų¨¤ĻZ§ŸqâÚ|`–\rIŗØb‹%ŲuÕ%b#Āzĩā‚ æNĪëôŗš‡ēVėŽØŦ/dÖ¸īžûÎ,ąÄ™k[ÔÅą_j–ÔüđÃæ_˙ú—šå–[Ėĸ‹.j–_~yĶģwo͝_?3sæLkÄüūûīæ„N°;å”SĸâÛđÜ5×\cūųĪšĨ–Z*öwã~áž{î17Üpƒšûîģã~5đų×^{Íl´ŅF–(!0”?ūøÃ.ĻS§N5ģ랋ųøãįųî~ûígnŋũöĀ:oŧņFķüķĪ›Ë/ŋŧáīë­ˇž9î¸ãĖa‡–Hģ“Žä‘G1Ë-ˇœÅÂ[ }žôû+U_Ĩd›˛Ljzč!ŗīžûÚîĸ/öŲgsú駛õ×_ߜ|ōÉfņŎ:ųđΓ¨øŧūúëæũ÷ß7:uŠú•’ŸÃqSj;K~Šī‹B0Ũzë­ūŌž}{‹ņāÁƒëBŸ”ƒå™gžiåoÚ´ifíĩ×.§ĒŠWü yTģƒuqôčŅs͕¨ƒVIģƒĩģTû(jŠ=‡îØtĶMį˛ĪÎ>ûlķŋ˙ũĪÚ^p@ā×ģuëfnŊõÖyūļâŠ+š^xÁđÛ[:č ķŸ˙üĮŦļÚjI4;3u˘1ÃÚrÛnģ­šúęĢ3ĶŽ¸ ‰cŋÔ,ŠAHĐ1cƘe—]ÖL™2Å´k×Î<đĀfƒ 6°¤†ų'Ÿ|bØ¤Ü˛e˸8Ûzüq[_Ú%-R‘™ūųmķ!y`ŠYiĨ•,É7 “oŋũÖ>ÃßŨÍŅ—^zŠyúé§HŪöž}ûšĻM›Z#†įĀģ{÷îæŗĪ>ŗFWÖĘ!‡bļÜrKK€ŊĨĐįYkÔöÄQ QëŒō\TRSéĶĪŪ|ķMƒa?đĀ­1‚Aųę̝Ú9 ŠÁHį7s`ķÍ7ŌŨšžšîēëˏqã :bWXä ĖŨRۙT;Ā‹ūnŗÍ6 U:Rƒ‘Qú¤,!2]t‘5BōVDjūą¨vúēYŗfą‡ē’vQėRíŖØ ø¤ĻuëÖĻ˙ū 2dˆÕĶčnwŒ8úŊsôŅGĪc§`ëq”øf›mfX`Ģë!5}ô‘iŪŧšĩmxĪV[meÎ?˙ü$š™:p‚6ĖLœ813m*Ĩ!qė—š$5Ÿ~úŠYa…Ŧāo¸á† ˛ā˛ø˙ã˙h 5,"DZ0bņœ <ÃíæL¤YŗfYOëöÛooŽŊöZŗĘ*ĢØÉÄŋ9ņeõÕW7/žøĸYzéĨįzCžI†ÁŧĐB Ö˙ÄOØĖĪ?˙lŊœ;īŧŗ=Ú‚0vėXsÆgØz×YgKŌ#F˜+¯ŧŌ’Ēc=ÖėŊ÷ŪsÉ “–Ī|đAÛŪ˙ûßÖ í-.RSˆÔā­ĻLš4ÉtA#^pÁÖŧ˙ūûĒ>ōČ#­1ŽęGqŨŲu×]žãģx—Ÿ}öYë…ÁC›ŸyæŗņÆÛÅ}Ũu×5(˙>}úØ!ú0aÂûiB11nxņŽAƒY/Í2Ë,c˙Gį—_~ą„ üvÚi'süņĮ[Ō‹'žąšųæ›íį”;īŧsŽĪy‘ĻW^yÅ*?R-h[žJĨdŋĸ’šJŸ~vĖ1ĮXųģãŽ;ēËyá…šŖŽ:Ę :ÔĘ1d #“9Î|’ŅsÎ9Į|ķÍ7Vvß{ī=;ß1nXdįĖ™cõˆwQÆĀ˙駟ėü¤Î zŅ=ŧĮ 2:…(mA–y×nģíf˜KD’h's€ĪĐ%xXiķîģīnįé[oŊ5Ī÷HI@/é+¯pąâM7Ũd?Ú˙ũ­WĶ_Š‘šÃ?#GŽ´Ž!ÚĘczÕUWYŨĖ3|†îG—aĸik%ŠN?3&ŽŨ#eā6C )ģƒ, d’úĐ÷Ũwß<ö 2‡ƒ™&EŽv ģŋūúkāēÉß°>üđC+F§všũŧBČЕAëmĐ<Ã&ķœ§Ė3æ.z ōgL#5ی‘=÷ÜĶ:­é—ŋ°öĮ[la˙„#›5Œ—’ÅÜ õōË/Īõõ¨˜Ųd`EÚ6…ņFođëã€-Š.Įî{ėąĮmA˛güļK›6mŦŧ­Ž6ėV։SO=ÕÚŧ^›kÍ5× ´kh#v-ōĶĸE Ģ÷XŋüōK{ÁŽŗš*ĄWÜ;âØ/5IjPđ fĄ”—~ÆbÆ$`ņbrcČ;âЁP l,$L\#ƒ…‡IÍ"ˆAíR¸XāG<Á‡z¨øLÖ ú!/]ģv5÷Ū{¯­Ã˙˛Ë.3;î¸ŖU"AD;đäņ^××_m…†‰ˆī ʁ+ ä… Ņ…áčŠ#5,œnq/eSŠĄ]%uÔ¨QÖŖ˛đ 7ŧ8í„ Ō>ūO€AØX˜!2d`ĮäEIzggØ'uŌI'Ųī?ŠcB„béĨ—ĖÁlĻOŸnĶâø œ!{<ĄÄøDŲQŋÇv{?‡LĄ0€øŪoŧaëČS‰Ŗ’ėWVIÍ;ė`PÆ4¨¸H ķ×ĨuA؃dC—ųŽL˛ÉœdĀ8Å(%Ö+ûäbĀ< ÕËBûä“OZ™ŋëŽģė\aDî!I&æ Î R2\;ÛļmkõD]Bģ0΃žwÅWę+oĘē„90~üxÛôs>xąƒÔ0WŊæØšįžku'Ĩ'I}S0! ‘€ã…yÁ\GWCų7cĪbŽŽÅ ##Y!ōæ`ž¤t gôpŨĪģ\āĸ×kô=īĮ 0`€u uîÜŲ:†ĐMČ ú\X;3Ū‹S†ūa(âčĸŊčŋJ`,ąŒjw°ž˛!‹IØŒ1ŽœŖČņZk­eƒ~{ŽL#¯Č0zˆčÄW_}¸n˛ŪaátD^!äĩƒ9hŊEųįŸy N˜íļÛÎĘéÅ_l×kz=s€T3¯ۅųX.ŠA/ccŅ'tŽ˙6„(˜â@˛ÉXНč@œKؒØ4ØtŒË%—\bõ(ī$M™Ī‚lAHßvaĀ Z7ŧöīB?°~°Ūxm.tC]ƒ\@úX;ЅŦá8’ßyįkoAŒ*]âØ/5Ij0`Yđũ†4AŒ×ÍĨŸ9Rš€ŠģŊ $)ixČ0𸲁“g0Œ™|…ÂĀ( „ĄéØąŖ˛8O€ŋ~ ”Šcķ°`"#ĢŽēĒ]ИĐ<ėŧ…E˙0ʨŸvx Ä ‹ņĸĀƒį u;RƒˇĀģ§–^.Šyøá‡­ŌĄ`ä@ČđH{ “……\XQ´ní'ÂE¤īŖp1”ø?ŠēŠÁBä…Å…‚Áˆ×"ŠąGŊ(Œ ´°ô3rvŲ[ĖđŖ‚ôēŦĻĶR6q”B’ +*ŠŠôég.Ī [`å'5DúAŋŒ˛¨ãÅBŸžFa>˛'‹t° ô3 pę!rRHöŠ—H6äÖyÉoZæ8ē˛āH F6 >ž|.,DxŪ0LüßÛ¤¯ŧ˜āėāŨx;ņž02Įi—ˇ@j [Î!Âßø?ƒrHM}SœPDÖ ¨ROß~ûmKj ŧĸč™.]ēXį íwŠŦ+>ė•bá‡ėā|t2˛G÷ûsÜŠ“č Î-ä Ō‚A‰s ŊãŒBȆyō,œ6ä–õ‰# Ī8kH%ŠHąŽŗ¨v‡#5á¤ėôō Ą-doāŧEˇ9™FƐ ä uûD‡ OĮčBä ũ… ô=œ:ūyFꗷ`“āôaūātųͤ 58‰˜¸‚nÃF(—Ô0ĮŠģģ[ĘSLąß‚l2°f.:‡-īĀ9DÚ>zŨĨ™ĸGÁũd búmÚd3ąnx÷1^DØąķŧ6×÷ß_ĐŽ!â‚cwBh¨ĮE"ÁŪ?–ië˜8öKM’&†…á…3‘0tũ¤†ŧJ”’wã†,cÁĨ_AX‹‘ČÄÃ……§kPũö,nÎķŽwbpðI#Ą°ĀōoŪ‹g!Å`b1gķFjđ0âÉ#> x ‘š´ĶĪ ģ31*‰"a0˛hC\a"CŽ\ÄÍíƒđ“0„”ŠÁ¨Āxķî`ņĮ“‚‘ Î`ȤD†‘ –3x ˇŒÍįŸ>™L{b—SĨPÎ{üߍJj*}ú^4œŪE”č'rä/Fē‹Ô@Ŧų›_F]*^I5D÷0W10 ‘/HG!Ų§^ŧ“čR3yŌ%1¨Y,]z'xãl!˛âH ‘h<#Lô!^Ut›˙{D¤ƒô•7UŨE¤€ū1Xņ‘šB{jĘ!5ŪtÖB˛‰Œ‚)úÜŅÍčrúķ ]ÃBOÁģJßđŗ˜ģôZE!Gjđ‚ō= ĸ6ŒQŨīß;ã'5NįôėŲĶâϐÂzå6˙ēĩƒ9IÅËZiRŖĶΌgQíGjXĮ“˛;0Āq° ¤XÕËēE„ų§ +(8ƒÖMlwP„ â-A4‘h'NÆ īá$đĪ3œ‹Ūâô†>ōĖūį RShOM𤯛~VH¯DÁ”yd“௉rē6?Ė_tŠŗQČĀņƒū˛qÄúmôlÍÄēm늟ԠKąšœ#&ČŽĄÍNˇĄS‰ĐāĀŠIŌ2*Ą.~AeĮËØĄCû›ÅßOj˜Ô X0ŧxx=Š‘„‹Ií CĪ)ųĖ+F0Ä"¨~F †$R„@á!ÁĢ@=,Ē„!ŠÜų;‹ņ&›l2OípŸŗAÁ‘ŊTx‘“<‘ššGËE*YH™[xÂqđ‡@Pú†~Œ"S…H ˛Éˇx đb˛OŊo"#8IR0œđ }$õ†1ã=ũŒvĸƒ #čČxĐ÷Đ%AúĘK~đö“6.¤Iā ,ŠÉŠ)†)xĐnÆ’Š'# 2D?8 ŨϐI~ŖËÁŖŊë'5fqt?ÄÃ[ ‘äįŧķÎŗÆÃ‘=ŪĩŖš¤Fü=’QíGjHJÂîāŨ^YĒ—5Œĩ"Ž]A$YbŋhĐē‰Œ;R™įy Ī2'ĐMAßc}öĪ3ī~Č?•d˛‚ÆššeRSSæmMy$uŒ”~œ A°3æ ‘,lˆ!™ Ā [ŋŲ.8Š‚Ö#2IÂH /d×xu›HM†Ŧ< V SŠ#1ęɇŦøt†<°ÁŦIƒaá lČMĀKjX! ü Ā¨Ÿđ?^\WÜ~ČĄ:O"&Aõ~ÅPvŪ@<Ãx&Xđ\Ž)íGaš ÷0{R$0¤HëÂāņ†M1>gōweÄŪFWŽtv)$("ꠃ0¤đ;( H¸#s]zD†Ô1ö$āņB!pĸ øâĸā‘F ĐwšŸãņĀØƒÔ`BüPÔÃwņ**'ę1dL M¤u@P ¯wÚû9 ŌD4Œ:‘‹ŧP”uRSéĶΐæēKE?āØĀ{§;čHgd*HF™§+¯ŧrÃ&U杛q<ėîĀŪë'5…dßÉ, #ú…¨/õĄ/Huƒ€Q˜ƒČĻ—Ô Ŗŋ“'Oļú„ô="AúŠ\kWĐ8đ ĸgĐ9—ëž+v¤ŗķVĻĨOĸbŠ“‹č ŅWŒˇ˙RIä36C$ˆ”áô`aG3` ŠC¯"+8] =čwôRŨOÅHKķ` ÂĢÎGNq`ąĻĐgīÚá'5Čą÷€Š4—g‘šŋŅjw¸#qœ&ew`#āä`ķ9Ų Aõ"sdŦ`PŖ3XķĐ+8€ƒÖMtö‘KĄâč7į¨)ôŊ yÆŪYoA—✅ܰ6㐤-¤išRėHg¤ũíĸ™Ū÷ ›% :( Đ܈‚)vIMFú0Ž%tŠÛdO”"įŌãŊŸcSŲ‚`dģZ7ŧ}ņGjˆT;›‹ŋŲ5^Ũ†~„ˆ)R“ĻöŒY7JÕ(W"€<åŧrˇ?'č„&;Fīõ–8õķ=& “ÆŪëŨ”ë}‹ŸÛ@ˆĸ–‡Ë ‹bœy÷˙°¸3ųéx:‚Å˙I ē'üooZõ P0ØČvŖ‘÷ųķDŊŸãe,xŋ×SĢöxÖIMĨO?s a 3—ĸŪ7$Ŗ…–9H‰"3Aõ’JzŠ˙ČyÚíŌžĸč5oƒžWH_šī1wØG†AC!*áMs¨š`‡ŧ8StÎ<Äxʝ‘A˙ø‚ę-čæ?sß{bYĄW'ĨûŊõģKb‘#<įY+:ũlŨᗋBöŸŗŪų‰uĐēEւžįŸgAõ5]‚ŗį!ē,ŠŽŒŌĻ´ž)„Šß&Ãé !KŊáM­wú[ĮûyĄ6˛]x>Îzä¯?/vMûĨ&÷Ô¤%Ėǎē`|zIMu[“¯ˇĮQ Iö,Ꞛj‘š$ûĒēō‰€#5Õ8Õ'ŸˆÍÛjP+#Š~$…€#5d Š”‡@ûE¤Ļ<Ŧõí "Ā&EōZ]čÎũĢâ(…$;•ÔTúôŗ$û¨ēōbŌôHíS) ‘šŌp͎jRü‰ū—rą{íĸRZĪâØ/"5ĨaŦo \!G)$Ųą¨¤ĻŌ§Ÿ%ŲGÕ%ę~Vī ū ôˆcŋÔ=ŠaC?qōŌĶ:Õ,ŌA ŽRH˛"5IĸYŧ.ö]°Į¯Ë }^š–åķM…Ö­ķŽ§ ¨ŽŒK̃ģŪZYâØ/uIjØčÅÉœČÉŪcôâ›ÜØ”ī ŠQœ Æ1}äh{ON‹[w۟÷Ÿp§?œĀÁ‘ĒÜŅQjIĸŽRߝĩīÅQ Iļ=*ŠŠÆégIöŗ’uq1‡Xp*—ˇp 'ų÷~Î)Zč=ŽvÍJAOrmbÎ'Y8A‘ÔFīĨt<ô ŠŦœ,ÆQ˙ÜÁÆ^oáčf{áwĐįœdÄ!.yģˆ7I|]]"5i \§ė—Ę`-ûĨ28GyKûĨ.I §EpÜ2§s$Aj HÜ{Āå}$.÷ä¤,Žå>‰r ķ(žÕgŧlÆm#ų¨œ@į=­uÄ}gVŸŖ’ėCTRŖƒĸŖŽÎé}ūS°¸ũŊã';îsîš!ŠÃā˛Ũé IDATôAœ•Â1öŨÎQŅū{*Ęm#§ĩqĸÄMá8dîc āˆiŽĻæDDR Ŋ…‹|i›Ÿė¸Ī9%#quĀ€ąwopI`Xá î ĒtŠ–ūKŖŸ˛_Ō@uŪ:eŋTį(o‰3ë’ÔpˇdåĘmŌx>1 šë„ûG8+#ņr Įéqˇgūûô$RÂ܎];;6x‰Îpž;—ųq§žSĸ9\ŪÆ}(,Ū܋ÃáœĪņĪ<ķŒõ&ōÚC=#ÚBārßː!CĖȑ#í{xžŨ[0dŽŧōJ{YīeįxŲAƒŲķā9ę˜ŗ`s9 ķŗĪ>kÚˇoozwĪw!pÄ-žsũõ×['mô^ÚįU ÔGŪ5^&<ŜĄĪ1ēŊœ›ŽAÁųúÜqÚi§ŲKĨ¸Ā‹ãTy?‡pK;žfŪį5@¸ƒ{}ܤ\\xéĨ—Ú:¸s(¨í\ŧG=´…sØGÆ#ˆ›y?ŽQ&`Ÿ‰Ŗ’l-æ?2Š1Ė˜Č'ō†Ėp }äRJî>âzNčCîp˜0'ˆÜēČ27~üxÃĖcī1뤒poķ€:šŖššĖĘĖįŽ;ڃ2˜ÃĖĸų¤í/u1ŅŗÜqÍ5×X]ä/ts‹Ŗ‰Â‰A/t:ˆû7ĐADE‚ę%ęâĮ–ؤË;‡rˆ"/Ü?ļ\Ž‹î$ēÅ]_ÜsÅ=\fJßšī‹;zĐÅAŸ3öô—ąāū,ģ0āšÔûÁ¨Ÿžp ēŒqDž0P‘0‡tq?d@×Ņ<ÜĪœ‰n„¸f)eZ$Š5‹×%ûEö ļ¨ė—āyR—¤†…Ãã•Å™´.Õä’:XPŌĮ¸€’…Ë‘.Đcaô?Šáo, äﺘšô3Oˆ†ãä Æxņ=.¨Ã €¤a´ĐWŧĩAm'uãŌI{ĀO7ŋyíĒ•’uR“åĶĪH?bžéĀĮøFžĐČm‡č`øBؙ+Č*˙†`°B!æĪP¯s† g\ļÉŨ)Ė%Ė;ōŽá‹ŧâātŽäžzîŧķNû{Ú´iv>}ΜGĻ‘í 6ĸ‡ˆX`Ä3géīÃ?lÉsœ:1´™č8UH†›cã8b0ēĐ?88˜Īā€~BĮx :’Ęšô9 >.•ËESx?æ$úŊ❄-$1éŌĨ‹uR1F'.­C/ĸ¯ĐĄāAßŅ˙čVnęF/ƒí úœņ‡čqK8D0¨]ü ŧq– ÷XwhŽ*ęf}@/ņ Ä=Mä‡ö‘–I‡ 36Č$‘1ÉJŠŠÜHČ~‘ũ‚-*ûE¤ĻøãĪ$ŌŒהÅĮŨ8Ž1‚׏ô˛0RƒGo(Æļ#5!¤@āŅcAƒ@Ŋáˆ7„S0 đʱȒb‚‘‚1Áâî.và °ĀŗĀmqo'‹ü6Ûlc#1xŠ—S§;dčĐĄ–di!úC!o4Ū` }ŒŒ <000ČüőđÄŗāRK0*úôéc †ũĨĐH‰ŸÔ@<0 )Sz´Í0Å8Ā3 Dƒđ˜;bĄ j;ī†l Zc5l”ƒˆÆ&īĒ•’uR“åĶĪpā‘Į¨GV™+nß Fį¨QŖ,ĄĀ¸ÆqĄ~É%—XO:ßáæfœDG™{8+0`ũ—ö"÷,V<‹ÁĪĸEj \æ4‘"•č!Œ|n{FĮ]`o æŽ˙sæ„ģœÛ¯ƒÚČí×č"ædC›ˆ ¤ gNæ9ī"2E; ĖE $§ķŠŋ!Ļāâī85ŧ…9É3< š :ĘĮIA4Œ¨vP‰"š=>ėŨ@—š´Tˆ‘+/œ( t sĸÃč%7ī˜ãčJôû‡ĐŖė%ÁY„åģô,Ŋ†:Q&ô‡  dƒö3ŸƒęÅQäĮ–ˆûzģĻM›Z†î$=Īø˛Į3čW°[tŅEmz0Ž ŅˆdĐᤁ‘´B킠BĮ‚ü°.`”BčXW?œ4¤”QÖR !|Č2ÆŪ°a­ĸũČZ–ŠHMåFų‘ũ"ûũ*ûE¤Æ"āŨ,Úˇo_ģ(ģh¤BÃBĘĸ…Wō€G‚Áį-Ū#ų†9 1$Ä{¤3ŪNęÄhgÄđfŅ"™StßSø;m!§šČQ<ŗxîđ ąÁ0"5RDŽ5NnW¨2ĪP (F†  ūMß1N‚Ž.äŨb,ĖÔ A ƒÅēX¤†ļŗÉšūņáũ…xŅG&%žRÆĪ#ÆDËģ§Æ;>/~r‰ÁĮžęÂ`ô’Œ¨Bc FėŸri>¤Ąa„‘ūQK%ë¤&̧ŸaØBęŨ JŧgēɈíÃh#ĩŠ4^08Ā…vPŊAØÉjÕĒ•=lĐ;¤šÉEw +ĐÕā@˙ üŸįĀČd’ˆYĐįô ' :˛PŅ)Ö Č(:™6AX)|FDbåđD ˇ.•į ē.RCJcâÅ- zJ§ŸUndŋČ~qļ¨ė‘š0Ōɍ;2˜\t7 đ¸…0 uŧzH‹, *ăÄ{ú‘ˇ~„•}9¤ŧĐV~ŗį…S”¨O*dÃŦ̎ŽwŪyĮæíķ>oÁ(ÂHaÁ[ĘÁï2Ņĸ'ˆ¤›ąWˆ‚7ĩÛGˇmaĪ—Ķö°ēŗūw‘šŌGˆ(ķC™ßDüÎ íæĖMī)}D)˜ģI¤´ã—ųä= ĢĐįQÚ†éPč,RĒ˜ËQ¯CÅ}à Ŗ'A’§tĒ7 lŅķčhœLŪRčsī3Ĩö—v]#ۃČÂ&īáoČaR˛6Îq˙ރâ"VŪķ˛_dŋ”'AųúvûĨ.ĶĪ*1œ¤¤°WƒßūËđĸŧO/Cŧ̤Ģ`P‘JáÍĢŽRO–Ÿ!Í -ũ„đā%ƒ(J˛ÄQ Iž9ę‘ÎY>ũ,Iâ(…$ÁŒJjtúY’¨Ģ.!PYDj*‹ˇŪ&ę 8ö‹HM=I†úZˇÄQ I‚•Ôčôŗ$QW]B ˛čôŗĘâ­ˇ zB Žũ"RSO’ĄžÖ-q”B’ ‰Ô$‰ĻęŲD@ds\Ô*!P Äą_"“švØÁ<ųä“ĩ€ú ęjÍߨ¤fĀ€æÂ /ŦģqQ‡…@- °ãŽ;šûīŋ?´+>ú¨éÜšsčsI?P-ũ—t?TŸ¨GâĖßȤĻuëÖfôčŅööw! ōƒĀ”)SLīŪŊÍäɓ+Ū訤f0M›6­xûôB! ĘC€ũ4ØŨģw­¨Z¤FöKčĐč!IâÚ/‘IM¯^ŊĖļÛnkzöė™ÉŽĢQB@#pÍ5ט &˜Ģ¯žēâE%5„—ZhĄŠˇO/B |æĖ™c¸…>ŦT‹ÔČ~ ũ]d¸öKdRsÉ%—˜÷ßߜwŪyŲėšZ%„@ }ûö5­Zĩ2GydÅŠJj&Mšd\pAŗÅ[TŧzĄĨ#™ÔD)Õ"5˛_ĸŒŽžŲC Žũ™ÔĐUŌC~øáͤI“ėõ\-B`~úé'ĶĸE 3{öėĒ •ÔüüķĪfŊõÖ3ŸūyUÚŠ— !PûíˇŸšęĒĢLŖFB+¨Š‘ũ:4z@dRė—X¤æ0—_~y¤ ™CG uˆĀ.ģėbŽ?ūxĶącĮĒô>*ŠĄq_|ąųōË/ÍųįŸ_•ļęĨB@ÄC`čĐĄfá…6}úô‰ôÅj’Ų/‘†H Ė PŠũ‹ÔĐĶūũû›_ũՌ1"3WC„€˜Č ÷ŋ <¸jđÄ!54rúôéfõÕWˇíVB ģ<ōČ#fܸqfȐ!‘YMR#û%ō0éA!PuJĩ_b“zzōÉ'›ŠS§š1cÆØhēvíj?üpŗá†šM7ŨÔplĸŠ•G€c9˛ųĩ×^ŗųíwß}wÕRÎŧŊ/•Ô°ˇfŨu×54äė¯ŊöڕUoBĀ"ĀąÍO?ũ´ĖŦ¸âŠæ°Ã+ ™,Ų/% ž$RC IûĨdRãzwŲe—YcŠŸÅ_܌?>ĩŽĢb! æE`ûíˇ7ß}÷u,lļŲfæˆ#ŽČ LĨ’:@š+ĮP˙øã6íuūųįˇ{úŽ;î8Û?Nssvęķŋ/.Â!yÁéa÷ŨwŸ!ĮBSęaAY!5˛_2ŗD¨!uŽ@ŌöKŲ¤ĻÎĮŖfēĪ=!QĢ#A RS‘ę%Š#€—ŸäD7nœúûô‚ė"5R“]¤Ô˛¨ _zôčaŽŋūz门 Õđs"55<¸qē&R-=‘š¨HÕîsÇ7gŸ}ļ0`€éׯ_ívT= E@¤&"=tËūķsÚi§Ųte•úF@¤ĻžĮŋĄ÷"5„4ŠIÕ|Õšä’KÚMå¤,͜93_WkE@¤&Q8U™1ö°*§_J9ŧB Ö"5ĩ5ž%÷F¤ĻdčôÅ"ˆÔÔˇxĨ6l˜5:¸ŧų”SNQ´ĻŽEB¤ĻŽ?…ŽĨ9ķĖ3ôË Aƒ­IįõÔSĻM›6QĒŅ3D@¤&ƒƒRMRúY bB]P¤&! ķ^HMŪG0›íŠÉæ¸TēUqH '9Ôļm[sÚi§™N:Ų&/žøâfūųį¯tķõž„ŠIHU#„@ "5 ‹€H! DjŌ@5u–’~öíˇßš•VZÉŧúęĢf5Ö°Ūi§ėŠiˇß~ģ9î¸ãĖįŸnú÷īo˙vĐAŲΈäŒ7Î :Ô|ôŅGĻk׎fذaöøW•ę" RS]üõv!PëˆÔÔúGėŸHMD ôX,DjbÁĨ‡=p,ô`>ûė3ëtųāƒĖzë­gzôčaúôéc.¸āķüÃvØa槟~˛÷āđ ĪCdFmČŽ†ė\vŲef×]wžUF@¤ĻĘPŖ¯WúYl ŨŠ)´ZüŠHM-Žjõû$RSũ1Čk Î;ī<3aÂ3fĖۅ{îšĮ <Øŧøâ‹öRĪÕV[ÍÜyį623qâDĶĢW/3uęTģgÁ´QĘȑ#́Í>ûė“W(jĻŨ"553”™ęˆė—L GU#RSUøŗķr)…ėŒE-ĩD¤Ļ–Fŗôž”’~ļß~û™Í6ÛĖôë×ĪžxĐ AĻI“&ö÷§Ÿ~jÖZk-ķÍ7ßX3dČ3cÆ û{õÕWˇ—}.´ĐB fOÎ +ŦPzôÍDŠIFUâC@ö‹DÂ! R#Y°H)HŌ@@¤& TķWgœƒčicË/ŋŧšųæ›íĘÎ;īlúöíkvÛm7ķŌK/™=öØÃŧķÎ;æë¯ŋ6;î¸ŖŨ[ÃgkŽšĻyá…lĒÚu×]gîŋ˙~3vėØüVƒ-ŠŠÁAÍ@—”~–AČHDj22Õn†HMĩG 6ß/RS›ãˇWqIÛ?ÃīĨ—^Úüūûīöä3HL˖-Í_|aöÚk/3}útŗŅFŲÃîģī>ŗųæ›Û.ēč"ŠYguĖW\aV^yå¸MÖķ) R“¨ĒRDj$ ŠÔHRC@¤&5h3_ņ?ü`.žøbķėŗĪš™3gšæÍ››mļŲÆôîŨÛFaĘ-ŋũö›ųîģī,éņŪĮûEfĘE9Ųī‹Ô$‹§jB`nDj$"5’ÔŠI ÚLULēcūī˙3'œp‚ŊKæŨwß5wÜq‡Ų~ûíMģvíėßųŲd“MėIdūų§šíļÛ˞Ë.kļŨv[›ĢRÛˆÔÔöøVĢwJ?ĢōŲ{¯HMöƤ*-RúYU`¯ų—ŠÔÔæCHūøã͏qcÛÁf͚YōŌšsg{WLÔÂ}3—_~šyúé§ÍäɓMĢV­ĸ~UĪ呚Zš,û%ƒTĄ&ŠÔTčŦŋFJ!ë#”Īö‰ÔäsÜ ĩÂŅĘĪ?˙ŧŨ OJYR…tąE]ԒĨu×]×,ˇÜr6]mĀ€ög•ü# R“˙1ĖbdŋdqTĒĶ&‘šęāžšˇJ)dnHjĸA"5ųF—NF…{_6ŨtS›Rvå•Wš 6ØĀĻ‹Ĩ]x7éj¤ŗq”ķėŲŗÍđáÃ텛¤ŗ)]-íHž~‘šä1UÆ(ũLRāŠ‘,XDj$i R“ĒÉ× ‰ųėŗĪėÉbDEzôčažüōK{TōąĮk[ląä_ŗFRŪ¸|süøņæ“O>ą'žÕųūûīm„G$'& Ux\¤Ļ  ë•B ŽŠŠŖÁ.ÖU‘ BˆÔ¤jru>ķĖ3–(<÷ÜsæŽģî˛Į$į­L:ÕëLô¨W¯^fŸ}öÉ[ęĻŊ"5u3Ôꨨ "5U={/ŠÉۘÔB‹Dj˛3ŠD88^™¨ÆI'dĶÉ&L˜`/ēŦD:YÚHĒöųįŸ›Ŋ÷Ū۞ŦvīŊ÷š—_~ŲlˇŨvöGĨúˆÔT jąJ?ĢÅQ-­O"5ĨáVsßŠŠš!ÍD‡DjĒ3 õ¯Üēuk›NöđÛ /ŧОPÖ­[7ŗÖZkU§a|+‡=@O<ņ„%5´éj\āšÆkXR§RYDj*‹wŊŧMöKŊŒtx?EjÂ1Ē‹'¤ęb˜+ŪI‘šĘB>kÖ,ŗīžûÚĖGa.šä’Ę6 oģčĸ‹ėáœŦvÁؓÖT*ƒ€HMepގˇČ~Ў/Ü_‘ɂE@JA‚"5i úw¤’…xéĨ—ĖC=d#?ūøŖyíĩ×ė a*Åātĩ-ZØģqˆlq ÂK,aSņˆhŠ$€HMō˜ĒF~&ø˙ˆÔHDj$Š! R“ ´îˆe6ÄcˆC`Ø;˛ų曛vØÁFTĘC€ĶԐ×'Ÿ|Ō\|ņÅ6Et5îäŲzë­ËĢ\ßļˆÔH„€H‘š4ŅÍQŨŠÔäh°rÔT‘šō‹MũŖF˛Ņƒ1cÆ$záeų­Ģũvß}wķĀ˜öíÛÛÃō^¸ä”‹Mũ…”ŧŗÎ:+°{.ņ•W^)Ģû"5eÁ§/ !‚€HDÄ" R#AH‘šč¨žûîģæļÛn3ŗ|Ė1Į˜Ž]ģÚMūløßxãŖW¤'SA€t?ŌúˆŪ˘1ÃôîŨÛFČvÛm7{9i^ Įw#[ȕˇp*Ū /؍K/ŊÔptöå—_^V7EjʂO_.€€N?“h8Dj$ "5’ÔŠ †–=AL\ōû˛Ë.3ß}÷M'Ģ…#–SnjTĖøņÃĨĨ¤Ģ1ĻĶĻM3Ÿ~úŠŋŦžŽF[Ią;vė|¸=h™!ĒsČ!‡Øä p|ˇmÛļfĈfÍ5×4ožųĻ9õÔSÍäɓ-ÁC–—]vŲyęŠÉˆđÖX3䔭ą-Ŗ;"5e€WK_•R¨ĨŅĖN_Djū ^gäŽ7ΞPF:Ų駟Ž;T˛#Žeˇ„ãĸ;ė0Ã!¤rõíÛˇė:“Žāāƒ6ė‚<ģŌŖGŗâŠ+Ú=Z7Üpƒ= œ‹X!3p€=LáöÛo7Æ ŗû‹zöėiûq9ōČ#Í&›lbÎ<ķLû›¨gȐ!"5Ižę D@ö‹C‘ÉĀ\H)H Ō@ žI QŽ&mi™e–1wŪy§M'Ãŗŋüō˧ˇęĖwØūã?nĶ× -[ļŦZKW[m5ŗß~ûÍuWQ§N,AyņÅÍë¯ŋnŪ{ī={¯{ˆÖ_}{¯Īˇß~k ÎĮlzõęe:vėhV]uU{,öÍ7ßl§<ōČ#6ēƒėû‹"5Uö ˜š< IDATš~ąŌĪjzxcuN‘šXpÕîÃ"5ĩ;ļÕėYŊ"1ė…Á d? Q™7ŪxÃÜwß}6"Ŗ#–Ģ)…Ųx7ŠjãĮˇ÷âtéŌÅFīîēë.ŗä’KšvíÚU¤‘jŌÅ -ūô°ķĪ?ßFié"JķÁ˜I“&™sÎ9ĮĻŦ}ũõ×ö„¸ûīŋߒÚųq…´6mڈÔTdTõ! Š‘ (R#HZ%5ąŒQJäå×_5K-ĩ”M#cöŅG:ŽzAm p÷Ũw›‘#GZ˛3qâÄšˆA=$õņ¸ãŽ3oŋũö\Õ˙ūûīfņÅˇÄœC)ēwīnī;âp€ŗĪ>Ûpbéiß˙ŊéßŋŋÚŦˇŪzļÍ{îš§Ō~øáö˙¤ßqdM‘š4FQu !PEj$Ej$i Pk¤†MÖīË Rz‹,˛Č\ûeŌĀPuÖŗfͲ—rēچnh–^ziá;ųä“MãÆ‚Â){×^{í<õuîÜŲîĩÜpĐĪpGĪgœa<đ@›2ץCiĸ°—†44ĸ‘'žxĸũŦI“&æ’K.)xyŠŌĪFUâC@ég ‡€HdA¤F2y%5ît2öð˙€ÔRĘ8ŌOļN'KMdTņ˙!ĀaDpHglÖŦ™ųíˇßėæ|"‚iĨĢ}ôŅGvŋWŖFĖĖ™3-ÁņžâöÍ7ߨ4ö y?'Zųūûī›ÕW_Ũ~ˇPŠ‘x§€œ˛i šĪ:Ejō9n‰ˇZJ!qHUĄ16˙īnÖ édŸūš5čH)ëÖ­›=^™ũ0œ`Å*B Ú@jHíš>}ēá ĸ:ŗgĪļÃ<‘š<ŒRūÚ(û%c–V‹EjŌB6gõJ)älĀrŌÜŦ“<áSKDæž{îąûT„@žxë­ˇĖ:ëŦcŖ‡Í !ĪjŠÉęČäģ]J?Ë÷ø%Ųz‘š$ŅĖq]"59ŧ 7=K¤†Ķ§HéaS?›ĨIŸac4ŋIéQyFŲæ42Ō%IŸ|øá‡í%šėËI+]-.^"5qĶķB@ÄA@¤&Z5üŦHM nģV-RƒQĮĻg.$Œ(ˍQŖĖŽ;îhīÚāŪ !PːNyÅWØ}9mÛļ5xŗIWã8gîĒAįWēˆÔTqŊOÔ"5õ5Ū{+R#AHJ“61sR§6q´rĐ€iôSu ŧ ĀaGu”Ųf›mĖyįgOZĢTŠŠŌõõĨŸÕ×xë­HdÁ" R#AH4I Šc\øĘ+¯ØceI#ãX\.ŊÔédɏ&^~ô„÷Ô̤ßÂ;8*›ŖŗRæĖ™cZhĄŦ4'ąv0š7onh&˛ųī˙Û,ŧđÂ6]2Ō("5i Ē:eŋH"5’‘É@j$Ej0ē0ÂZˇnm 1 ë=öØÃ`;í´“ŲrË-SëC*žđ mitåŌî¸g‡åqË 'œ`ė-÷rQnĸ_nšåĖFm4WH—jÕĒ•Ųwß}ÍĘ+¯l~ūųg{ņcĨ „øöÛoˇ—HR¸¨ōôĶO7/Ŋô’mé‹Č^­.Û;vŦ•ŗ .¸ĀĘ$ķ…^°ékI¤Ģ‰ÔÔĒôTˇ_"5ÕÅ?KoŠÉŌhTą-R Uŋ†_ŠážŽëŽģÎnæŋ÷Ū{ëōxå$IÍũ÷ßßpbŅKŠÔrČ!–„ūë_˙šëõcƌą7rd1GWšÔp)åĻ›njž˙ū{ÃßkŽšĻM_lßžŊŨ|ßącG›ļUoĨk׎–ėũ„č•SDjĘAOß-„€ŌĪ$‘ɂE@¤F‚qH GĶŪqĮæšįžŗ7”īžûîv“?éelø¯įRˆÔ`|2Č9ŌF? Ž,œyæ™fĈfĨ•V2't’éŲŗ§= RŗĘ*ĢØ˙oąÅëŅŖGÛŖ­˙ũw{K|˙ūũ-܃6×_ŊœP0pũ‘ęŖ"D9Ž?ūxŅāxlÚÆžiĶĻŲ6päpīŪŊm:×Í7ßlŖl.}$åé̝ž˛'Ķ5nܸÔÜ}÷ŨæÜsĪĩÜ9čáę̝ļ÷˛ [ °˙&bGĘÚi§6—˜`„͆Ī>ûĖļlxwP!ZDe:wîlŽēę*‹˜PhĨž÷hqz §¨ŊO܀čėē뮖 F)"5QPŌ3B@”Š€HMŠČÕØ÷Djjl@3Ō¨¤ųÃ0‡Ø`{ėąŠîŨČ<‘›QˆÔLž<Ųá¤MqĢ;„˛Đ¤Iû9Įürx҆>øĀ´iĶÆÜrË-æ”SNąˇÂŗiüĶO?5ë¯ŋžõÂ;RsÆgX"CÚDķ§Ÿ~2ģíļ›#/Šá–{ŽČĻ毸į]ėĪ€@ņ.R—ĻL™böŪ{oķæ›ošƒ:Ȓ)ÆōB!Ŋđ†n°īēëŽģėv"5x`éËc=fŋÃ!DLúôéc–^ziû<ŋųÃúÖ[omĀ”ž,šä’R´Ūzë™C=ÔFZH{ ę/ŸAŦ8yąÅk¨‡t=ˆw™ĘßãÁ{˙ũ÷͏?ūhOS4h…f⁖lRH„TēĪ7ß|s+—Œ+2Fá7i~}.ʑÍM! R#°ˆÔHŌ@ *Šqī&JƒÄū u ac ‘ ȌÛ{Bdá‹/ž°‘’_~ųĨÁ „,ļlŲŌFŧ0BŲüūÍ7ßXRQ!Ŋī°ÃŗP3f< Y€@0(DÎvŲe—šHÍã?nŖ:'N´Īpę„ãõ×_$5ͧO7…ŌĪ 37Ũt“Ũ7D”ÉĨŸÅ!J@¤†ĘÁlvØa3|øpÃūĘĐĄCíŪ^RãÚLTī@†Ø'R¨ŋ=—~ædÂĮwzč!ŗõÖ[×­(t{j DđˆÚ@f‰F-ŠÔDEJĪ !P "5Ĩ VƒßŠŠÁAÍ@—â’o“Ų8NJ)hxâIM"ÕiņÅĪ@ī*ׄB¤†2"[ėwĄ°åÛoŋ53gδÆ8†:ãĸŗÁXO9҇#Ž8ÂôíÛ×ôë×Ī’Ō‰\á@ĸ,D/N>ųdû17Õs °7R‚Œ‚F|yä‘ķ"-¤ĻÅ%5D|H•cüy7$#šŪéŪëîcņ“šŲŗgÛ}X|ļ>øāƒöÄŧ ū˛‡ÆOj8Uė!oõVˆė‘ęĮÁ¤"/¤žA×^{í’āŠ) 6}IˆˆÔDĒÖŠŠõŽN˙Ę!5ū“ūÃĪ?˙ųOkČsžb o<÷ĩ\0Ŧ1Ėoģíļ†n6jÔČ~F$cč )bD-ˆÆ°…[åŋûî;‹ŅŒ3l D€h˜ŊķÎ;6=´5nŸ'rCĆũ%Ë/ŋŧ­ƒē‰úp2!/ŠĄˆ'uķ›”˛e—]֒+ŪEŊ{ H DŒˆ‘Û§â:ŠápRāčũ#=ŦC‡vû9Hâ}ė́čxI ϔ¸I“&YBGÛ8˛ų ę/‘ŌˈNA¤)_~ųĨŇôĩZ.¤“AH9NģS§N6ŊŒ4FÆœ“ŠR‰ÔÔ˛ŠoB úˆÔT 2Ņ‘šL CÍ5"IRΕW^iĶ•0Č0|Ų A†<'qmVRÃ|o!âB4eįwļŠWÎ å1<ę§/ŋü˛ŨĪzö“xt†\|øá‡C“ē!Löœ°ŋŌYāHßE]ÔŖîŨģĪs‘ŗķĪ?ß>ĶĸE ģ˙…=lÆg/Ę2Ë,c6Ûl3ëá‡ÔBq‚ä@NŠ‘R0¨9„€4;ˆûhØŖÃ^™‹/žØ~žâŠ+Úöą/Į[ˆ2‘ĻF˛6DĄ‚úÛ´iS{*Ü +Ŧ`÷"Qč?î˙Y‘‡$ہŖ€(äōé“$ßC]"5I#Ēú„€đ" R#y°ˆÔHŌ@ mRãm3F+Ņ H ä/?^yRŦjš°ßT!Œrĸ+ŪÂ~ˆ¸„ˆŅ ‰+`‰ˇžz‰r*D4ˆ­ēęĒsđĀg”f͚ÍõU(ŸšˆHXÛ _ėķĄ´‘ũ6 ˇįŠ“ÕˆØ¸t;o}¤ŪA|h›ˇę/‡"øÛÖž<üĖØ¯†ō ™ü‚gĨîߊɃ¤¨B ŋˆÔäwėmšHMĸpǞ˙C ’¤Æ:‡ Áp'Š‘jE4‚´ĢZOWĢu„Ā™! BEDŠã˜KŨëQkxAFŨŪ!N"ã˙ÜkŽšÆÍū¤j‘šj Žw úA@¤Ļ~ÆēhOEj$i PMRãīQ.wÄÃOĒikD đäŗ„t+•ü Āxm ąÕV[ŲHMŊĸuD_ˆĻ} Ō؃D++E¤&+#ĄvÚD@¤Ļ6Į5v¯DjbCĻ/D@ K¤ĻPsŲŌĨKëŊæŽ 6ĸ́< píĩ×ÚËQ‰JžũöÛ6E/ËE¤&ËŖŖļ ü# R“˙1L¤"5‰Ā¨J|äÔ¸&sG—Iry¤;Yã‘ŲD­t5‰vĩ Œĸ/lę'Ĩ BÃ^!RĘōPDjō0JjŖČ/"5ųģD[.R“(œĒė˙ČŠņ—=˛‡'Ž%æhaŌÕ^yåŗņÆ×ÔéjØl!aá¸jNj#Œã¯§NjŋārĶĨ–Z*[ ŽØ‘šˆ@é1! JB@¤Ļ$ØjīK"5ĩ7ĻYčQžIM!ü8a‹Ŋ9xĮš[…#‹U„@p§Š55j”Ŋt´–ŠHM-Ļú"˛‡€HMöƤ*-ŠŠ ė5˙ŌZ$5nĐ0<šŌ2Ā­ë­[ˇļŠj¤Šb<ņÄ6Œ“ÜÎ<ķL›NŠáHéjN–öˆ‰Ô¤°ęõ€HM}CīEj$i PˤƏ׋/žh0T'L˜ĐpyåėŲŗÍäɓErŌŽÕÉéd'N4mÛļĩéd\hzꊧÚt2.Q]gurÔ›Ō›*RS:vúĻáˆÔ„cTOˆÔÔÅ0Wŧ“õDj‚Āũá‡ė^Ž‘îÕĢ—Ŋ'DĨ~āŌĪvÚÉîËęÖ­›šęĒĢę§ķ=ŠŠëáWį…@ęˆÔ¤q>^ R“qĘ[+ëÔxĮkʔ)6=Ã8Fzøđá6Um˙ũ÷¯O}Ūä7N{‰ž<øāƒæŠ§ž2ČũB -dīĐ!BSĢédqđáY‘š¸ˆéy! â R­~V¤Ļ†ˇŠ]Š) >Ū{"8ŋũúõŗĮH“ēÆąŌėÉaNĒdRČ /ėŠZ~ųåí~˜ž={ÚĶĘ8@"/G,W]‘šJ#Ž÷ úB@¤ĻžÆģ`oEj$i RUŧũl‡ėÜ|ķÍ6e‰Âž ‘œxXĻõt˙ūũÍšįžkīŠ!,ë^Ļ…C)õŠÔ”‚šž#„@TDjĸ"UãΉÔÔøWŠ{"5Ĩ˙É'Ÿ˜VXÁĻĢõîŨÛ|øá‡6]­oßžĻyķæĨWŦoFB`ƌæÚk¯ĩR"4ĶĻMSē`$į}H¤ĻDāô5! "! R ĻÚH¤ĻöĮ¸=ŠIuސ&‚ĶŠS'ŗŲf›Ųt5"œœÕŽ];ErʀšHĮ+Ī™3Įė˛Ë.6ė†n°D’T2í‰)\ĪWEj’ÁQĩ!Œ€H$Ã" R#AH‘š4Pũ˙urš€>ķĖ3æ‹/ž0-Z´H÷…5Rģ7c¸9Ą bHŦC‡5ŌËėuC¤&{cĸ ZB@¤Ļ–FŗŒžˆÔ”žžZ‘šĘ  4nÜØĻĢ-¸ā‚ö”=öØÃœtŌI•kD†ßDfذa6"CÁĀ&ėë¯ŋ6K/Ŋt†[^;MŠŠąTO„@ŠÉâ¨TĄM"5UŊ^)RSŊAæt5.ū<ūøãmĒÚ|`nŧņF‘`oN-<@$† ĸž|ōÉ6ėã?ļ{d {J'ĢŽ\ŠÔTwŊUÔ "5õ2Ō!ũŠ‘ ¤€HM¨–VįO?ũd.ēč"{$qãÆŲ¨ikË,ŗLŽI"UD¨ø÷b‹-fÚļmkĶĘH)SÉ"5ŲĩBÔ*"5ĩ:˛1û%R0= ‘šH0Uõ!N÷âđ6Ä8ĐtėØąĒí‰ķōQŖFŲŖ¯šßįŊ÷ŪSYđĒđŦHM@×+…@! RSGƒ]ŦĢ"5„4ŠIÕtęätĩ_~ųÅn”']C>ûė3›ĒÆe Õ,ît2í# CJŲčŅŖÍškŽiļŪzëj6OīŽˆ€HMD ô˜%! RSlĩ÷%‘šÚĶ,ôH¤& ŖPZ؃sË-ˇØc¤{ôčaHW{õÕW͆n˜jē„åŖ>2ĢŦ˛ŠM';üđÃÍôéĶm4‰/šä’ĨuJßĒ*"5U…_/5€HMÍq´ŠÔDÃIOÅC@¤&^yxúôĶO7#FŒ0Ûnģ­šõÖ[M˖-k6idũúõ3Ī?˙ŧšúęĢÍ~ûí—XŨǍúˆÔT Ô!PˈÔÔōčÆč›HM °ôhdDj"C•ģ9p€Ô¯VXÁĻĢíŋ˙ūfũõסéjü+ît2ŌÉĐ=§žzĒM'›8qĸMS:YîÄ!RƒEj"Á¤‡„€(‘šĢĩ¯‰ÔÔڈfŖ?"5Ų‡J´bŌ¤IæąĮŗéj=ô=a ‚¹ʐË[laĶÉ 3ƒļéd]ēt1k¯Ŋv%š¨wT‘š*€^/j‘šā¨ŨЉŠ”ž‹ƒ€HM´jīY.ŧėÚĩĢ!2ÃūN+SŠ_DjęwėÕs!P Dj*rŪ!R“ƒAĘaEjr8h 7™ûcúôécŽēę*ŊQŠ_DjęwėÕs!P Dj*rŪ!R“ƒAĘaEjr8h 7yøđáæėŗĪ6 °‡¨Ô/"5õ;öꚨ"5•@9īŠÉÁ 尉"59´„›ĖņËė­iŌ¤‰™9sfÂĩĢēĮ-ņV‹Ô$Š*4ƈÔÔ§<ņÄĻ{÷îfĈĻ[ˇn §ŸŨzë­æÄO47ŪxŖißž}}‚SĮŊŠŠãÁWׅ@ŠŠČyx…HMF)mŠÉߘŠÅB -DjŌBVõ !"5’‹€H! DjŌ@Uu |" R“ĪqSĢ…@^ŠÉËHĨÜN‘š”ŽĶęEjętā}Ũv—o úF@¤ĻžĮ_Ŋi# R“6Â9Š_¤&'•ŗfŠÔälĀRjî"‹,b PŠoDję{üÕ{!6"5i#œ“úEjr2P9kĻHMÎ,ĨæŠÔ¤lÎĒŠÉŲ€ŠšB gˆÔälĀŌjŽHMZČÖwŊ"5õ=ūŽ÷J?“€€Hä@4ŠIŨÕ-R“ŖÁĘQSEjr4XjĒøė”Õöõī‡ ˜ž˜ŗO1ĸ ¨` D%P`Î "‚ˆ$EP0 "’Äœ@E `FAŌ9ĸ>Äô­ßy˙;¯hǙޙéîĒŽ}×bÍĐSuīšûVUŸ]ûÜsōŒ€HMžV÷B åˆÔ¤üđĶŠŅ…DjōĒúÉD@¤&™ë&Ģ…@RŠIĘJåŲN‘š<œŌîEjRēđĶVø™ŽŠŅu „@>ŠÉ'ē ę[¤&A‹• SEj´Xy4U‰ōn‚ēŠIĐbÉT!@Dj¸hų0Y¤&¨ĒO‘] RŖë@JŽ! ō€HMžNH˙"5 Y¨„™Y¤ĻOŸ>nƌnéŌĨnÚ´i†@ˇnŨėíÚk¯ĩú<ž8´k×΍1Bë•ĐëvøđánûíˇwŊzõr 6ŦđSHJM…ĄĶ‰B@䀀HM Ĩᑚ4ŦráįXQR3eĘwØa‡š•W^Ų}úé§nà 7tkŽšfá' …€p .tŗfͲûąAƒîĪ?˙Ŧ*"5‚M' !#"59UꇉԔú g~!5ũúõsģ랋kŅĸEqŒÖ¨B@ŦHÍgœá† )‘šHpé`! "" R°R=\¤ĻTWļ¸ķŠJjÚ´iã5jä.žøââŽŅ…€X!š_ũ՝{îš9#%R“3T:P RSĐJņ‘šR\ÕâĪ) Šųûīŋ]͚5-ÄEMø#pË-ˇ¸u×]×5oŪ<'cEjr‚I !PADj*\Š&RSj+ųD!5™ęÕĢĮÃpY!„@NĀ#×=6"59Aǃ„€¨ "5ŽÔNŠ)ĩĮ|r%5sæĖą´ŋdXRB 9üķĪ?nņâÅ9,R“L:H " RSAāJí4‘šR[ŅxĖ'WRĶ´iSK{ĀÄÃpY!„@N|ņÅîņĮwmÛļ-÷x‘šr!ŌB@T‘šJ€WJ§ŠÔ”ŌjÆg.š’šzõęš×_Ũ­ŊöÚņ1^–!P.ŋüō‹kŨēĩ›4iRšĮŠÔ” ‘B ˆÔTŧR:U¤Ļ”V3>sɕÔtéŌÅŨ|ķÍņ1\–!3(ŦO<ņDšĮ‹Ô” ‘B ˆÔTŧR:U¤Ļ”V3>sɕÔŦ˛Ę*ŽjB@$ūũûį”ÚY¤&yk+‹…@’ŠIŌjåŅV‘š<‚›âŽEjRŧøšzjøã?˙Ęk"5å!¤ŋ !PDj*ƒ^ +RSB‹ŖŠäJjîŋ˙~wÚi§ÅČr™"„@ŽˆÔ䊔ŽB ŸˆÔäŨõ-R“ ÅJŠš’Ō9“V­â€ß_ũĩ\ņŌlŸW|¤tœI1XūeƒÍöy:P Ÿå%—\âŽŊöÚr!RS.D:@J RS đJéT‘šRZÍøĖE¤Ļę×âškŽq?˙üŗģņÆ—éüųįŸw;wvͧOĪúy“&M\ĮŽ]˖-ĢŪ° öøũ÷ßģmˇŨÖlēķÎ;+ØKøi'žxĸCüôĶOŨV[meAōöŲg7cÆ ÷ÛoŋšC=Ô]zéĨî˜cŽYĻœt Kf:ëūķĢŽēĘÕŦYĶ}ûíˇnŖ6ĒRģ“Öß‹-*×l‘šr!ŌB@T‘šJ€WJ§ŠÔ”ŌjÆg.š’šŗÎ:ˍ5*>†ĮØôUW]ÕmŧņÆËX9`Ā÷õ×_/Gvüį×_ŊŠŸ|ō‰Ģ]ģvlfxÆg¸GyÄũûß˙v¯žúj•Úĩųæ›ģ¯žúĘAø5jd}Oœ8ŅĩjÕĘíļÛnî7ŪpoŋũļÛf›mŦøk°5kÖĖa[&ŲņŸoąÅîđÃw˛´7‘š´_šŋˆ"5ņX‡ĸ[!RSô%(Ir%5qÎ~öįŸē+¯ŧԜá_ũÕ]}õÕîœsÎqÆ ŗ4ļĖqîÜšî”SNqƒ r[nšĨëÚĩĢ{āĖQŋéϛƔŪŊ{ģŠS§ēvíÚšëŽģÎũë_˙*[wšöÚk/ˇß~ûYŸ›nēŠ9Ր’üŅŨvÛmŽ"Ĩx 3fŒÕôA-āX”l;÷Üs]ķæÍC?ßyį­Tž… .gc­Zĩ\‡܋/žčžüōK‡ķ~Įw¸ŨwßŨė8î¸ãõ„P5ĻL™âÖ[o=7tčP+˜Ęg+­´’{îšįlžôuųå—ģ×^{Í5lØĐŨ~ûínà 7\î‡ÄuÔQî–[ną=UÔ<ĄzęŠFŧēuëæ{ė1K÷=vėXSEÂúEuÉÄå`“M6q|°kßžŊ­ĘK:u ÛúõëģN:™ēõđÃ;ŠH^|ņÅî•W^ąš=ÚŊûîģĻė„}ÎÚ3_ÖâŊ÷Ū[Î.T‰™3gēn¸Áúg.ožųĻ[˛d‰­#×ĶĮlטCēFŒad•kU f͚åž~úi×ŗgO÷ųįŸģã?ۈkfX\1 Ę~VLô5ļ‘] †€H.„| P ¤†p#Ô ”œpœoŪÎŖ ŪŅÁņ%´ įŸP'~‡ā°žđ F<ĀŌÃ1ô !đíƒ>p;밃#ŧŒb†{îš§ˆŽ/ĄNė]āˆÖe—]faUô3~üxû9gÎwë­ˇ†~Á7nœ9Øa6BÂP,pâ!Ė÷Чž22‚“ĪX8ڄ‰A  BÆĀgŧW¯^n=ö0ō͎o_SCĀ2…ãlė˙Ų{īŊŨŲgŸmN<øøP.¯Ļ0>ĒĘŗĪ>ëvÜqGˇũöÛ/×/Ä. [HkrėąĮēÕV[ÍÖōôōË/ģožųƝ~úéFrƒšīēëŽîčŖvmÚ´1’^Øö9ëŅĢ[ˇŽÁ0ģøxŖB5hĐĀ 6Ũu×]Ö7dg§v˛c 4,| IDATŽGq„õ šáúÁ>L˙‘Géîģī>[Ž H"k—ĻDqY Ų!Ō€HMē×ŋlö"5ēō@ޤ&ÎŲοΟooäqęycÎ> ŋo§søđáF(pŽQ pԇ boŌ9g„ öƃ 60•…7í8°(-ÁÆ˙=zØą8üBŖPXppqÜQ~Ķ9r¤9ų~øĄíį@]`oČėŲŗÍŅÎüü§Ÿ~r\p)tP¨¯ŋūē[guÜ?ü`dGńĩ!ĖĒmÛļâÅX(SØ@ĩ‚Đ@ōP@ /üíüķΎéĄhđw”­`ÚįŽ…\œp F÷Ũw_›ŋŸĒPãÆŗö;pāĀPlQ6P_ ØĖqiĶĻ1D]AUB[ˆ#đ,„p°–Ė/ės֔žP˛^zéĨĐųB:PT(,Ëõ1{˙ũ÷āņ;’ë%ƓfHÜūķ#`īŧķŽŠ_\+\ ¨;4ú„ø‚W\šHM\VBvt# R“îõŠŅúį\IM\ŗŸöE¸ędeŌ¤IĻ´āXŪAā­;J jN:¤$¸‡ĩB€"I€đ°Ÿ#Ø.ēč"ˇîēë9âo8ŗ~ŋ4*ĄXk­ĩ–‡Éĸļ„m°Īüq#)ũúõ3e"ĖFT œhÆ_c5Œ¸Ŧŋūú…í8ų؄úÂĀOOŪPh<ŅÃVBžPk|ƒX āøFøĐŗęÕĢÛĮ¨3„īĄ1* ˛D˜ŪŠú Ãö°ÃŗuØlŗÍ\Ÿ>}l} K'Ÿ|˛EÔ.~2&Ą}„ŖA@h't’ũ’ö9Š ŽaķĨ/T"Č*áoü#´Ĩ…ĩ€Ąô@Ij0oŪ<#T`)ûîģīŒōû¨|#ĐyÅĨ)ûY\VBvt# R“îõŠŅúၤ“ÂĮØd l Egĸ‚3ŠŖL8ÄáŧķÎ3ĨƒP!~ļhŅÂjBČp„ ߂ˆpŽ8Ę*!^8ũ8˛8ㄐ=ķĖ3š^°`ũ­{÷îEx„А&BÕÎ<ķLSbÂ>'œ âˆę„ƒf#*’ßãÃŪ ÂĮP]îšį#((&/TH :Ž9{apŧiČ DˆũCö’ œ Tp.sË ŖŽĘÄž æƒB‚"†ũŲúe_L&ļ("ėëaíjÔ¨a$ ĨŒđ<ŽIև=ށ¸‚ŨškŽiäU„š‘ ûœ00p@IËfEh˙ũ÷7uōCûh t„•ą~„ˇRF_(6„ŸZáãēâcoØ Đa?×ZœšÄi5d‹H/"5é]ûefŽđ3]ų@ WR×ėg8ļdŋbĪŽ(Ę Ž*oŌqĘQE '¨8Ŧ|ŽƒMØŲâŋmß N:Ęoŗ ëĸá,Ŗ@4اÃųŧ‰‡l ŽĐ įúÆ˙QW8G—†‚ĀOø–WÂ>'Ô õ§™~Âl¤Oœė&¤Ž1æ‡˛9ãĘˆņ ¨OÂęh>ø ‘6xp Jd åĸÄ8(A`ęDûpú ­bc=Ą}¨āōä“O†ö†-JÖvÛmį~˙ũwÃbB¨!x-’rČ!†ķƒ@ōŽ?”7Č$ŠYØįĖéŗĪ>3˛šmžˆáwQT=l‚°Ōøė­ˇŪ2båņäZ€ÜúP>ŽƒčzĨ†<Ö$ˆ[>îÛ¨}ŠÔDELĮ !DjōjûŠIāĸ%Āä\IMœŗŸĄB°gG™Ÿ(8ŋ+j8æ8˙Á4Á¨ŧá¯*‡;p~ ‡ fÂĘöyĻŊa6–wI…šCH ë†’Ų 8ũ[oŊu•féĘÖoU` ‰!œŒTÍÁ–íķā1/vŖŽą~ŒChaĩjՖÓŋqVÕĩSŪ:Gũ잟EELĮ !DjōjûŠIāĸ%ĀäR 5 €Y& ĸ" DE…_ƒ !đˆÔčR0Djt!ä\IMœŗŸåõ)J ‘šRZMÍE$‘šäŽ]•Z.RSĨpĒŗ˙C WR×ėgZH! ĘG@ŲĪĘĮHG!Djōq"FŠIÄ2%ÎH‘š/Ųß˙íø§ęđ‰ģČdpŅPĸ€ĸ/ B€¨ŖØšĒ–zDjR ä€\IM\ŗŸå”@§d#MoeSôž}öŲ–Yl^qj>55[¨“kûúë¯-û›ã+Ķ(::vėX׹cĮœē!3YŲŽ<ōČŦĮįrLNƒ•ĐA"5%´˜šŠH0"5 ^ŧĒ4]¤Ļ*ŅT_\IMœŗŸås5̊Ô@ !5Ô[‰Sķ¤†=QÔ(˛ē‘ž¸˛¤† oā’k?¤K&3D([Ëå˜8­A!lQöŗB Ŧ1„€(‘šōJÉßEjR˛ĐžĻHÍ˙īŊ÷Ü駟nub¨Q2xđ`÷ŌK/YÉ­ļÚƊ=ō9N3)“īŧķN+€‰ĘqÚi§Yĩ{ŌũR…= ¤TĻö BŊRŠĄŽ Îß{īŊŨ¤I“um.ŧđBĢAC M6hĐĀjäĐu\vŲewķÍ7›ĸBjí`3Å+ŠC ’šE‹Y맏%ũSŒ5RöØcYJŠn’VŲ“æ‚}ØJĄĘ°>¨aÃų`JŖæÍĄ‡j…J)pIíÆ 6p bC}ŠĢRûŦŠ/Ä\QŅHĨLALÆCáÕ(øøö+ØpJP0¨5+@@¤F—‡! RŖ !äJjԐũŒ"ž{íĩ—Š)W\q…9ÃālŲ˛Ĩģ÷Ū{ÍaϊC† 1RÔšsg õĸ ŒB˜Į=÷Ü㸠5’Fš~ũúąÁް>¸Îîžûî˛0ž-Z¸ãŽ;Ί]RØå‡ú:žÍž={9l(| Ž`wß}÷™3fĖ°âĄŗfÍr͛7ˇ" Øé\FÁ/÷XúЉÃ*Č! Djt ˆÔčČš’šRĪ~FQGœj (ÜÉOäwŪyĮĒÜųå—ļ§œrŠ’ ØtčĐÁ>GŠ4ĸĖ@ˆpĀi^H ÎāĶ'xŨēumŒmˇŨ֔UĶĻMMą ļl¤æÛoŋu{îš§Š¨)7ļB›*T ‹Bļøû§Ÿ~j ęFĻ$5‹ĢŽēĘÔ(ũ2>DˆĪų?Ę„cuÖ1ĩvqãÆŲ9( Klō§ÂĘz÷îm•ī}C="ÔëĻ›nrûīŋ˙2×{ÔlēéĻîŅG5U‡vĐAYxW§N,ämŸ}öqä­ųėŗĪŒ@AjŽŋūzS… qœ) ë#“Ô@~ \ŲHM6l˜¯',E”(ė{ˆáŠHMyøy"ZŠ% (ÅU՜„@ōŠIۚåÅb‘šŧšúNs%5iČ~F¸ûE7CíXc5,d,ŒÔ⌺˙fúôéæāŖØ āp.* ę$=0}úô1R€"qėąĮšNHdčĐĄĻrÆø„Ē3{uíÚÕH ĄfŸÖ­[Û˙GeäëōË/7"Ř ,0’ÅūȤ e %BRŠĄ/ž3„’q!vģ̝žÚH›Í!c™ņãĮ—튚öÚk-íÍ7ß45(Ŧöņp qĖ?ūhŒ3ÆH d23û!zaØĖ›7Īpāß{ėaĄ‚üƒØŊ'5ū˜¨øĄ@•bŠ)ÅU՜„@ōŠIۚåÅb‘šŧšúNs%5iČ~†# é ¤ §lاrĮw˜ú@ƒ,āˆã ŗ9gžëėŊAA tŊ(ú9üđÃ-L-˜Ō™Ŋ%¨~øĄ…ƒâ†ōÂfwHC0 j á]4”’@jPH@Č„ōžˆ õŲŖB¨Ē҉'žhęû]PH‚-˜Ō{AŖq.d…P˛>øĀÆúųįŸŨúë¯oķŸŌ™Ŋ: ļmÛ:öʄõA8˛Ä>æØ­[7;ž~ €ĶĻM+3= (E™ØÄĩ Ō bĀBČߨķūDÁ¯TĘ~VĒ+Ģy d! R“Ŧõʛĩ"5yƒ6Õ‹Ô,ģü8čŧíĮyĪĨĄd@ Ȏl|ŽēÉ)¯}ôŅG–ÍĖg$Ë<RĀ~™`ƒĖŧ˙ūûn›mļqĢŽēę2#œŽP7ÔĻ(ąP™87ÂÆ^BãØ7T^ËÖĄkĨĖ}CėO‚¤…Ų† Ąl´5jØZņ°c\&{Ŗ‚ĮTŋō昴ŋ+Q@ŌVLö ŌD@¤Ļ4×5ōŦDj"CĻr@ WR“†ėg9ĀĨC„@"ŠIä˛Éh!PrˆÔ”Ü’VlB"5ÃMg­\IMŠg?Ķu"Je?+åÕÕ܄@rŠIÎZåÕR‘šŧ›ÚÎEjRģôšxŠPĸ€-ļĻ*bŒ€HMŒ§Ļ‰ÔíôŒ•+Š)Fö3ö`°9ŊV­Zy_öŒĐØŖ–|ˇ"šEž{x¸Nã^ãF¤&ßW‚úB DjrA)ĮˆÔ¤`‘‹0Å\IMĄŗŸQ …"dą"Ĩr>Ų˛æÎk)”)°I­K/Ŋ4C´O˛ĩ1§Ė ų5â˙#5…B ixR1“ü€ėkQD’á ŠF=?ėxČõŊ÷ŪëÎ9᜞?Oœ8ҞבÍnĈV‹Č.­Š1̞e?ĢJ4՗E@¤ĻĸȕØy"5%ļ 1™N\I uGúöíkEķՂÕéK…Ԑ Œė_Ԃ!ŖZą1ŠCšå(tÎÔÍ!%vÔÆ5CęmHkU5ŠŠRdôíˇß.ë’ÔŲ¤í&5uŖFbMj”( ĒŽõ#„@eŠŠ z%tŽHM -fŒĻ’+Š)döŗ/ŋüŌjZ˜7õD$05c¨C cŠ]Μ9ĶwÜqŽMĐÔ:Ąæ õSyäKÉÜącG+8ékŽ´oßž yRīģīž–˜‚‘°DâįįŸnoä):IŖđ%‹ʧvš_­ZĩåV‘0Mš4ą*÷ԗšå–[Leâ<ėĻH$møđáVĀ’°: HnąÅĻŦpĖã?nĮ 0Ā‘œĄS§NVÛ˙ĩ×^ŗZ5ˇß~ģŠ0÷Üs¨Äyŋæšk  |Ro†ßoŊõÖelDÁaė9sæŅ`^Æ ŗú:\¨VT•-ˇÜŌĄ–@.ПŽđœ1c†ëŨ쎛:uĒk׎+…D…5²H Í9ôGІnļŲfFN8áKåĖƒJŠBĶĻM-tŒ5;æ‚"rōÉ'ģ.‡)ã0×Áƒģš5kē­ˇŪÚŌ_3ˇlíĨ—^2Uė˜؃)s¤^ŠĢ™#ŗæ\[\G(2gœq†]c't’ŠIŒ>^ŠĄžĒ9˛6ØØĢW/ĢņCM0…°5oŪŧ O‘š‚ĀŦA„€(‘]"†€H.„| +Š)dö3ę–Ō3nÜ87aÂs qvq0q)ōąA]Ą $N?dg‡G˙Š+Ž0G}úôéŽZ'8°8Éžą‡ōäÉV ĸ‚“˙ÜsĪ9jĖPlĩã×_u;í´“9ūžÔāœg*j՜~úéVĩ€ņqÂqęqŒébBŅNœÛĨK—Z1LzœN k2goū!k‡z¨Í Ra¨íĩ×^FæŽ?ūxë›yáŒCԘķÅÉîķĀA/đ„„P‡†ãŠõ™€´Bâ(jÉôĪ|ø‚Ą|á…Ŧā%키p ŧ/n™y}B!™āų™={ļũĈĘ×_m$á“O>q7ŪxŖ­9ã˜#Ž8Âl…Ė0wä! SŠvBú˜3ëENˆ˜‡ĩ ¸wÜŅŽ]wŨÕ֌ŸĖ 5ãyÛŗgOģ(¤ 鄨 kGAUæĮĩÅõЁ¨ļnŨÚæūŦ¸ąžŦE\Áë{ÁĄMŲĪ ˛ÆB <DjĘC(%ŠIÉBxšq$5@€CËÛnۚCPxŖĪ›xöIđög“Ÿ8—8™ėÛĀš†ņvGBC?ß|ķUŦĮš&Č ?CĩšíļÛlpüqFq˜9Î;ŸāÅ[üĖ}>띀ŧķÎ;æXCÎ=÷\{#OCqA:öØcMąĀņö/+PœxĶ_ˇn]#28ĀÕĢWw(V.ԍķĪ?ߎ‡\mŧņÆæ\īšįž6÷Álįā8ķ;z°QŒrʔ)æP3sō{N a! Ē DiīŊ÷vC† q+¯ŧ˛)fāˆĒA¸*ã@(`*đđ$eŅĸEŽũ+(H8{†5ß Rœ‹Í„¤Ąė€˙oŧ‘õrô˜€ë¤I“ĘB받P<öĸ0gæK›7ožaˆjÆ8 ėcŽŲ0Bō<ύv0͖ũŒk‰=2(^4æ Ä.+ÄÎgŨ#”BŧæVDj ØG}´Š7Ŧ}ĄÖĄq]ĸ„Ņ J:‘šĒ|šŠ/! ⎀HMÜW¨@öIŠ)Đ)&WRSčėgŲH ËƒĶ yĀéfŸoķy‹ˆJjP‚ø—™(Ā““č pBq†ƒY°° 2Â>˜ņãĮ›‚BXä…p3Âĩxķ_ŋ~}GøoūŲ#BÎ4÷7o÷!=öŠÆœ Œ‡‚ÂūÔ”P§ØīSždÉsōą™ėq(3„G͟?ߜuėG)`ī D‹ã!;"úC%â'aWd1#Ŧũ$œ )ā8lČÖ H„…Ņ/*ķC"” ŌĮžúāķ… ZčáŦ+ĄY(āÅ °0LąŸũTybßLŗ5Ö%âƐOČkŅ€xJƞ(AHXeöĻÔp ĘûĒ|H"JÍë¯ŋnØ˛v„!Bxn¸á†‚‘e?K؃]Ķ1E@¤&Ļ ShŗDj x:ÆK ЁāhŌ <ė[  §GåĀžŪŪķvœˇė+RjQÂéĮ‰ÅŠfÃ>á_4_wœOH ĘĒ!MŒˇæšk–] „ĩAxžúę+#„Máüsp8œbT ”~§áāû=#özĐĮņļ…ķLãĘ Ä‚ũ@뺁ØāœŖN@ju#´ "ƒĸ…cä86ŌŖ: Ę°QĀĻwČ s}ķÍ7mė'$‹9žĄ@ũƤáô{ĩ#ķn bÂŗ •B6ôE¸vąV(48p€Đą …†=)7ƀĆaĘ|<\ėŠb}ek$i€lq¨L(Y„BrĀ劐JŽeƅ„ĸ°ĸ)Q@!PÖB@”‡€HMyĨäī"5)YčO3WRSČėgš@ĀžTB~*ÚPbhåõņaķ|Ž ûč7Ø+Ãą…Tāāû0/ HIfƒŦ°éžŦ^8Ę4BĘP' _d‡ķ \ X™ķd°ī§›†ZŸĒzEÖŗO‹ũCjB@| R“TاHM-&įJjØKPŪž“LW& Ô!Ā~Rg“dA¤&u—‡&, Š€HMAáŽī`"5ņ]›$[–+Šáú+Tü’ņ”íB ސÅ.—:g"5q\=Ų$J‘šŌYËJÍD¤ĻRđéä,äJj¨ŗAú\RđĒ ! 3š\šHM.(é! *Š€HME‘+ąķDjJlAc2\I ŠĄB]5! ’ƒĀņĮoõ2ĶT‡Í@¤&9ë*K…@ŠIâĒåÁf‘š<€Ē.]ޤ¨( IąKŠ0Ē !úôécYÖ(æšKŠÉ%#„@EŠŠ(r%vžHM‰-hLĻ…Ô`ō'Ÿ|b׃c2™!„@ęá<ņÄîēëŽË‘šœĄŌB@T‘š €VЧˆÔ”âĒNQIˇ˜ ëšV/ū,eHũõ—ûũ÷ß#OZ¤&2d:AˆÔDĢ”Š)åÕ-ŪÜ*JjØ[S§N×ĩkWGĖūöÛo_ŧIhd!rHÛLŠÉĒęS¤F×@˙ūũ]īŪŊŨ•W^é.ŋür’bDjRŧøšē("59 CˆÔ$a•’gŖHMōÖŦĒ-^gulS9!K .ŦęîÕ_‚ŠIĐbÉT!@Dj¸hų0Y¤&¨ĒO‘št_¨4}ûö5RSŖF ךsgŠ5)ž$DjRŧøšē("59 CˆÔ$a•’gŖHMōÖŦ*-ö*īSjMUĸ›ŧžDj’ˇf˛X$ ‘š$­VmŠÉ#¸)îZ¤&Ŋ‹Ti< RkŌ{=0s‘št¯ŋf/ō€HMžNH˙"5 Y¨„™)R“°ĢBs×^{mˇŌJ+š5ÖXà 8Đ]tŅEnŅĸEŽlhWUK"5é[sÍX‘šBĸãąDjbŧ8 6M¤&Á‹W ͟}öYwōÉ'ģ¸O<ąŦ§1cƸK.šÄŨsĪ=ŽqãÆ•A§&‘š$ޚlÉA@¤&9k•WKEjō oj;ŠIíŌ/3qŸŌYh¤‘št¯ŋf/ō€HMžNH˙"5 Y¨„™Iōūų'aVËÜĒFāčŖv\ĩt#Ā÷ĖQG•n4{! ō†€HMŪ MVĮ"5ÉZ/Y+’„€ž/IZ-Ų*„€H&"5É\ˇJ[M@ĢV­Ü Aƒ\ģvíœw:FåÎ?˙|7~üxפI“JŖ„€×\sëŪŊģ€B@!7Djōmü;ŽYŗĻ[yå•ŨĒĢŽęæĪŸīÖ[o=ˇxņbËNDą<5! „€B@!Dj’°Jy˛‘Jßŧ=]ētiŲĮëŅŖ‡ëŌĨKžFUˇB@! „€B jŠŠZ<×*͒%KĘėĻ8^đ˙‰› B v(ü,vK"ƒ„€%‡€HMÉ-i´ ÕŠ4ҰĶŅB@䆀䆓ŽB@Š# RSqėJæL¯ÖHĨ)™%ÕD„@ŦЉÕrČ! „@I" RS’ËmR¨5W_}ĩ#DD{iĸa§Ŗ…€(…Ÿ•‘ŽB@Ę! RS9üJâl˛qÆnĈŽ4JI IDAT5! „€B@!$*MjėŪ|ķM7sæLWĢV-7uęÔ$Í_ļ Ä#pāēŸ~úÉíēëŽöīŧķÎKüœ4! „@ž˙’o„ÕŋX1UíŋT˜Ô<üđÃŽe˖V¨ą^ŊzŽ~ũúöOMÂ#ĀK…ˇŪzËŊûîģŽ/ęxĀy䑅7¤ÄGüíˇßŦļ“Íh ­đŗhxéčü" ˙%ŋøĒw!Ēô_*Dj.ŊôR÷ŅGš‰'ÚŧšņA€ēC-Z´°— }úô‰aļ¤gĪžnôčŅnĢ­ļr't’;å”SVh›Ųi§Ÿ~ē9r¤ũžĶN;š÷Ū{Īņ6 ĸ¸Î:ëØ‹œ›ožy™žPĘÖ^{mwá…ē.ķ7öŠąoíģīžs7Ũt“ũNąÛu×]ˇĀˆo8% (öyYäŋčŠņE ˛ūKdRÃĄZĩjŽ˙ūņEE– !ā.šä{éĐ¯_ŋÔĄņāƒēc=ÖČȏ?ūčŪyį{ŗÍ6ÛdÅ“šÚĩkģO>ųÄ-\¸ĐH m˙ũ÷wO=õ”ƒ(íŊ÷ŪŽyķæĄ¤†ĐŋAƒ-ķˇÎ;Û|ûíˇFjø}ŪŧynŊõÖKÍēˆÔ¤fŠc=Qų/ą^'ʨ¨˙‰Ô ŲŪ~ûíôB@$BĐÎ9įwÄG$ĀÚĒ3ĩäŪ{īu/Ŋô’{îšįlū¨/¨0ŲŽ÷lā~øá÷Í7߸YŗfšĻM›ē5×\ĶBk{ė1׸qcwÜqĮ9œŖ9sæ¸ŗÎ:˔œcŽ9ÆŨvÛmļŸ §wīŪnøđánã7ļķŸ}öŲåH ĘΘ1cė¸Ī?˙ܝ|ōÉîÚk¯ĩžoC"+á“O>éÆŽëžūúkwņÅģîŨģ[ŸW\q…ûôĶOŨ.ģėâēuëfÄ ÂôČ#Ø|!vͧOŖĐ ģÂĪĒîÚVOC@ūKÅpĶYB XTĉDjVYeˇxņb…œk…5ŽˆˆĀ’%K,,Šû6íŽģî˛û¸×^{Í~BŒ2I ũŽąÆFHîŧķN#?‡z¨‘šõ×_ßČĮĮėļŪzkÅ(“Ô ^@@ +­´’Š; 6´˙ˇjÕĘŨ˙ũî„Np;īŧŗ)D&ú`S%$Š1 cķ$â Éá˙Œ‡íØ6ÆĢ¯žšēkAN/ō_Ōģöšy˛ˆęŋäLjN=õTwĀؗ¸šÉA€ˇū8Á~ķ{r,¯¸Ĩ„š,X°ĀņÜb? !ZÁa=~Šų÷ŋ˙í8Ÿķ}ôQ׊S§åH ņžį°‡ũ:ˆß˙ŨBŅž˙ū{ gÈQ&ŠiĶĻ›p›ožšŠ0>ĨŗWgčË'`` É Kėq!;ŲŌhČHfJg’ ø ÂĐ3ƒmąÅ büDaĪ Ę ŲUƒt‘¸€Ī8ĨŌzĮw,Cj ;ŲÆ(äU,RSH´5V&i{ūé Ĩ„@”û7gRŖ/ĨRēD4—´!Öûrķ¯ũËöŋäŖĄŽ°ĄŗÍ6[Žû¯žúĘÂČV[mĩíûØtĶMO+‹­Ėđļ\2™UtŒ\ėČå…Ÿå‚’ŽÉi}ūå Oõ+ ‰@”ûW¤Ļ+Ŗą„@‘ˆōP(’‰V!ôüË ŦęT(÷¯HMA–Dƒâ"åĄP\K5ēB jĐķ¯jņToB DšEj š2++ũõ—…ŪTĢV­`(ũņĮeY§ 6h‘ŠōP(’‰ļ„PøY /nĻĻį_I&FFŋ‰Æ>ËRnQîßԑǁ“ͨsįÎe×Y’(´Gæ!ŌŦ†5*ƒ“’5ŗ‘UŠúÁMĮÄš“öēëŽs5jԈŨĩFšXjqP­5.Jéĩk×vįž{n^M"= ëüįŸē6ÚČũķĪ?y/.Gy(ÄÅfŲQ:čú+ĩLâL’~ũ͚5Ë‚üũ÷ßeûîphŲO7{ölwØa‡9öđe6 īN:5tÉ(*LũĢO?ũ´ėīø2d_ä<ĩx"ôßČŦI"›K/Ŋ4žÆV‘UQîßT’Ō;véŌĨ nČY’îšįˇhŅ"ûüÄOtûîģo™“íA%Ŋ6Ķ -d,‚ŊüōËæ$Sŗ‚Ôד&M2:nšdn;vllL+Š!ĢÕ3ĪKŨKV)°ĸNEŠ'B]$ˇ¤Š'{ŲîœÂĪt¤?˙Ę#5|—ŅÂü{˜OŗõÖ[ģAƒ•Em@ŠˆdáĨíÚk¯]ļdø~ņÅ-ʅcž#yYĘ÷7Q-D]ā'Úžī[>c8✠! ûŽ'm=Q$¨E¤¨įģ™Îø$™ū ~Ŏņđ‡ˆ’á÷Ė—Éø6={ö´ķŊ˛1gÎץC7sæLGūßõŊ/žøÂ"xæÍ›gŸSųĩ×^sÔ1Ã'Xēti菨ƒ/F ü=|Œ ,sųŗĖŊ^ŊzfJ …žQÜPä˜ ~&sá%ŧoø{A˙2(nü¤fū~-ˎ,dxUŪīQîßT’.Ô “ʍÆEWRÃE9tčP#;ŧå@ÂĨvÄ>ûėSļļÜŦ¤xåæáīüÎȁ'5=zô°úÜŦ(=Ü8\Ä nTnHO¨(˛ĮXÆ s<œ (K} H 77ÍüųķÍQĮņõÕWífƒTáÄĶÍō@ä†åæâæáÆģõÖ[ ›Ŗ>ÚđÁBî ÔßČu\ˆߞūyÁ‚…ŧ!Â6æ^ac1YpŖfÄEėŒ3ΰ,d RăÂc= Cž1WÎ'¤Œ(˜ČC Bé9ëŦŗėp„26Œ¤>˜K”‡BU>ŒÔ—B Ø$ũųįI ßgū{ˆƒ”šĘš“N:ÉčŸūŲaög<ũôĶË,™!šĀ/ā˙™ß‘|¯ˇŪzæ_ė¸ãŽöŨŒ“īqā;īz^äâŖ„}×ķÍ÷9=Ņų>Æ'jßžũrū ~õÁ ØéhÛļ­ų|ĮûF /ÆâE2ūŅ6|§CløŪ‡hŅĮ{īŊįüqû˙ũ÷ßož˙Žēę*ķöŪ{oŗ˙ˆąÂÆ%b[đEč‹c ƒÁø_{îš§Škø{ØLųž8Ž9Ō|6ßPã("íũ7|>ÆâÅîO?ũd$ BJņå0ߒmIlQîßT’Ūāŧû†ŗЍ Š9ķĖ3Í‘ĻĄŦ°7'“{RÃMŽCͅ‹* O{衇Ü&›lb5/(šĮ[66Ä…ˆãCQÚnŧņÆ˛‡@Ԝ`rŪ.`aOH˜4Ū¤đ† “Ô@B~øácöô uãa@8Û×_íxōļ§2Âۃ°=5=nFz˙į†œ>}ēŊđŸ_{íĩö6€ŋE7¨ÔđĐãM‚ããã}yķ6cR=ďãļÜrK“iy@ųˇĖ†šÄ:g&Čļ§Æ?8yķļ |°“e-Š-ĘC!Šs”ŨņE@ágņ]›4X–ôį_ĄÂΞ] |Oã0wíÚÕ-X° ëw$¤‚ŌÃw*ßĮ$0`ŸŽ÷¯Žŋūzķ)ø~ ûŽ÷¤ÆG¤ ]Áw|˜?Cr^nōĶ7ÂÔéÛ7^rB°ü [^ÔâÃpŒ÷Į Bšøū÷aj(2|˙Ŗ¤Đ<Šá…iļqQ_˜;6Ŗ|Õ߇WjĀEĘ+5`ƒoäI/oƒ!h+ÚSƒ)Â/ ķ-yqœÄåūŠÉĶžš\H͌3Ėųæ"F=Bq!¤‹›‰Đ2.N$anF¤ØvíÚ؃æÎ…ŗĮ{˜BÁÅĖÍĮ[äN” ú&‹/H1ŠKŒ(D‡ÁÆė ŋáyá'ãÆG>æMŽ(ãŗ­qŗķpŖOŪ `7ãķæ$l,Ūx0.dŒøWōHŠ aė„Šŗo’€¯Bø{Gđs|†5BŠÂžëyĄünö¤æėŗĪõgˆHųđÃ-RÂÅŪcö×îžûîe—’÷7ØßƒOi`/JŖFl¯ ęûwxЉ?܀ŸÔā„‹QŊzu{i AB‘ÂŽāKQü6ü3Č ę~'5ė‰a;ęļáīpũú†¯âũˇĖDžÔāS…ų–™‘+ų¸×ōŅg”û7•¤&[JįLĨ9ĶĢ~ĄP++/QŠH !M°wbbŲĶAė&d bÜüÍ;Ô°udRŽCuāŌ'Î?aSŧi@"å†Dšáxė2tĐA™Ô QâxŪĸđ€07öđøF"ŪdÜ{īŊö…“‡>¤‚~‹ƒœpÃáäGbåYæt!v(0{ZiŲž#ũŪ|,üüžŖqÎņMđs ø!$õ ûŽ',HjđGø~Į? ķgøŽ†đđÎīD¸Ø ؝ÃoBÆ ø&Lp­[ˇļˇœ‹˙aƒ,ā_°×Öī5*5&ÂŅÃÆ%rßBŧ úYØÅ>"iōÅ9žÔ@‚°ōŜũļ?ö„{˙ _ČÛÉß!5¨dø…až%žV[”û7u¤&Ž ĘMĖřšgƒ›Œ‹ ÂãCŠ`ũ(6ŧõ6dTö~đ  ˆ>úČۜVåLžT -ÜH„ēA¸øé7īãüŗąŽãšé*:nđ<Æām ũcNW4Ve׏žÁ;˜Ų%[Ÿläá ŠLz‹ōPHú\eüPøYüÖ$Méų—ŸÕûŽô{p §Ą~°Īƒ}ø9A˙Ą"ßõaū ã0.~O0Ė=sÖė—ĄœCĐ?bS=amØ[‘hŒ°q!3īŋ˙ž%!€D…5ÆĨũ ü:ˆūķČôņVäŋ…‘͡ĖĪՐŋ^ŖÜŋ"5ų[‡*뙰´ ŠŠ˛Ž3:‚‘ō5ļĪf@2–dģ)ķe‡ú­zĸ<Ē~tõ(„€(zū{OjP7ÔVŒ€'5$DPûQî_‘š\9ėį€Í3`äËlŪdK2pdfíČ׸ę7ŋDy(ä×õ.„€(,zūīāh¨ (4„{Ģ­˛–ąŊA~ײ8EšEjt—  åĄ84Å# đŗŽá–A@Ī?]B šDšEjаÎdũ`‘2‹9˛Į„ÁxĪ"˜—ˆ!}˛…`V¸Î^§ląą…˛9ĘCĄP6iœô  ë/=kĮ™ęúĢēU‘˙Ry,åŋDÃ0Ęũ{RÃū˛OųÆŪǍ’7œFšbRū‘"/n ūlŠķšŅŊ}dŖMf ŌôwJ!HŌ˙ų ?nķōö`'ĩsØ{Ĩ‘Ņƒė'ŖGŽrÚ2Į˛î`E]87ę3šËÖ(…bŲ¨qK]ĨģļI˜YąŽ?ų/+Åöō˙RūŌ$ŅIЁ@ Hįū‘r‘ĸWîŦS§Î2ëDÚF I‘@€ (Pƒ rũû÷7 (ôIŋĶ$ƒůFŽi¸’ä€"PÔÆ!˙;)ĸąĪ“°ĻæGALšâY3>Ŗī™gžiŸA霏>úhKd4>;捪,­cØ|Č[Ī úâ‹/AfÍČgŊõäīŦ°eë‹Z=aØrŊœuÖYîąĮŗÜķ×]wĨ×FŠá‚ā9Ę#Dŧ­X_ꅜŖÆB@„!PŦįŸüų/ō_*˙LŠr˙&BŠÉ$5JÄŠ¤X¤C"ß9NûÔŠS-sŸã¨SĐA¸Ž?…Œ(âØ´iSĢÛ2nÜ8ûÉǂQ8Ķ Z’…žqãÆF ‚ū)€I_ ÅŸČĄN5Xh~FŊ—ãŽ;ÎÆ‚LāDãÜCFČjƘ2(ú ’ŠCA4(ÖDe[s ‡hAĘHš šāsŠN2r SؒtŠîĸđõmHÕ !‚ô@6 [4æGyoĻā~ú¤x'$ oßžļˆĸœā )'ōģC KđĨ¸l¯Ŋö2bN`ÆzBjZĩjå:uędd“B™žA& 8Ŧ/8uíÚÕ°kذĄĨļūņĮ­X'$‡ųņ7BÚ(JŠ›!‹'BÁ‚ņøŒ ČĖˇ6ālŲúĸ rļ\ 6úaŨ‹âcžÔPв ĄÁ–Bļ(…BÚĨąŌƒĪ˙‡Ÿ Ļésáīëë,X0ģPw]Š‘˙"˙EūK´;0Š˙’HRƒcŽƒHVOjpxgĪžmĒĘNãSO=eÎũ7Ūhoči((ËyC_´>}ú8ŠOâĀãėĸШœ‹CŽŌIjø2FB‘ĐāČz‡œũ0„™ą§†7øüg˜Š@q€@JpÆiôƒ$58ū*Ķ !Ø Ąá'$ƒy0ī7ß|Ķ }PHŠ3(%!l>Ã.°C ‚Āy;ąõõ"XüúõëgÕsŠ” IYiĨ•Œ –AČžüōKë›s!/ōŽCNä'Jz!|üđä!ˆ/Jc2GlŠ^Ŋē i€āą~(S|i0ˆ‹ŗÍgøđáF(!I„,‚ŸŅˆŠÅĖÁˇaÁ Ã˛ ŲC Daãz#ށšsįšJÃÚĸä0^![”‡B!íŌXB@|#PŦį_Š‘˙"˙EūK´;>Ęũ›HRÃTBœ<ŠÁa%$ •¤^Ŋzæ\ĸf@j8Ž0,ęoéÉ0Fč˙hˇŪzĢŠ<( ˙9Î+Î|ŠņÕqúQhPwÂH N3Ėģi'žxĸ)(J¨ (4”~’œ{pˆ˜o„œáčC (TĄ…Ȳ…“ßŗgOS]PBPqPL<ŠÁ^ČĸRõîŨÛ­ĩÖZeũCÔHTā:Įķ0†vÚiF6<ŠAUÁaįÎŧ8Į%Š"Rŋ˙ūģ‘Ö‚GȤ†=p˙đןqąŸ=T` Á<ōČ#­oBĖP䂉 ąŒ3ūüŦķ A°P}XÛ6mÚ!ô­f͚6/ßV„͐!CBą%Ô åŠ°8Ū ~öŲg﯐˙–ˇt¨†"5Ņj:Z!PQĸ8E#ėŧ0R#˙Eū‹ü—hwY”û7¤†pÔœcBT”œ\qūEØŽ™` ^”呚íļÛÎö›@Ŧ˜˙‡H!A Ž=öXs‚ƒ¤†žqúQp´Ų¯™‚ė  °×dæĖ™bvĘ)§qÂY' …‰8Ќ•Ij˜Šû’ ¨Rô #œĖ7ŽAQjÔ¨‘…Sa;$ \ŅđēŸūŲ-^ŧØTĨC=Ôö”€?Eˆp;ágėb>¨,—`#ŧ‹°=0Á~ֿ̝ž˛Đ3Tǰ‡Ä!Û|Pu<ŠÁFlGÁb-īCé‚[ļž¸n°…0Ą`Ąœq˛æd´ f?Љö0ĶŅB@Ę"Å)ĒėXÁķ!5ō_äŋČŠÜ]åū=ŠÁÉö›˙‡ĸŗKķÎ-Î22Ę* Ę$‡¸í;î¸ŖLy!ŧ Ĩ†ũ8lx'l‰Ŋ8Ûö=@&PBØPÎ^‘`8–×§’F‚x„)5Bē M8ęôÉ8ėŸÁŋéžĪ!*8ėÁ”ÎėÍA)"ŧ ōĀžÆŖē…RCĩ^"’žF;Â珙ĀĨ •ã ƒ˛šĄŧĐ 2„ōAčŸMė(IÁ†r…Ё‚ŌÂøėKၠ9Ą’ņ7°DíB"Ė ˛ÅZ‘äÅō‰>ĨŗCƒ4ųF(jų3æāSy{ĨR“m>=Ô(ȍk‚k{iŦ1 _°eë+ļÖrĮu4‰ÔTîĻŗ…€•A ŠST™q2Ī•˙"˙EūKåī¨(÷oėIMypōÃv” áoŋũÖyoõƒaTÁž „›ąg…FØoī}M|B×éĘ,’YžMa§¨&ęĄH¨1ÁF&7ŸVTīŌYƒäÚPfP† zėAĄ@­kœoČaXc? ÄUÅã‰€ČøĖp_|ņ…)OØ Ņ‚Dp,™į°GŋP­ŧų`˜°ˆkhEOÃúʆ-Ąz\[™iŧ 5īlãDy(ÛV/„€¨Jâúü“˙’}•åŋd÷ĮäŋdŋnMjxëÎū˛~Eu˜!@(&„ĄA`§" @M”qũR/5œ5! â‡@Ÿō_âwČĸx"åūM4Š!‹ĘA6uĄŧåA‰aoJû7PjԄ@)"åĄPŠķל„€H/q|ūÉIīõ¨™GC Ęũ›hR -Ō‹@”‡BzQŌĖ…€(Eôü+ÅUÕœŌ‚@”ûW¤&-WEĘæIx!ûyĒb?T)@åĄP ķÕ„€=˙hœĶ– IDATt-$ ų/ËŽV”ûˇ¤I ލoBX™˙,c̝žēíŖ‰ē'—›‚Ėa÷ß˙2,ŲPOí˛ŸQĢ… ÷dÅb“=Yˇ|˛ú'™Ņ¨ĢÂæuR(¯čī¤b&ųi–ƒZ1d!Ël]t‘ÕsŠUĢ–e~Ŗ‘aŒ”Ųdí"‘ HZ@†42ĘąņĖg¤eĻđĨod+{á…–ĸ]ģvVo‡ē4$o`ŸRfĢ(ö>ƒ™ĐHÁO å°Ff6R^ķ3Ø(|Ėū–Ëš–Â1Q Ĩ0_ÍA!dR#˙Eū‹ü—˙ŪÁQü—’%5dÎđ:äĀ˙Næ1Ō 7hĐ /O|Ÿ<˜ĸØŠh’ŌC:aŌ“mĖf¤ƒ&Ŋ0õR¨ųrà 7¸:”ûw˛§šēE‹ˡÚ0oŋũļĖaįwvĢŽēĒ{éĨ—,ģX°Utu$E"¤†ėeŲ˛–‰Ô,{yGy(äåÆP§B@"!´įŸüų/z)ûŋ‡E”û71¤'"!Ā‘ÅYoŌ¤‰Š =¤@#N7Å3wŲes´Šƒ’cÍīj¸PėōĒĢŽr{íĩ—k¤îĘ?Q$PP.æÍ›g5VP6(ā‰Ō‚"B*h ˆC0E2é¤Iå‹ēŅž}{+d QŠS§Ž2­ĄŽP„’Éô‰ÚAuûN:•­ 6‘^™:'+ú;Šv“*šÔĶÁÖŦY3Sj¨A“Ų R~N 0ōCí iĸ AĖP[¨=‰ĄžÍ-ˇÜbĩj°›öÎ;īΤ‰&5ÍלÁ°ķiļŗŠ)œCqJę‘´"‚=üN-"Š•˛Ļ—]v™kŨēĩÍ$5Œv,}‘ΚZ9`Ä *5IÎeŊ™ E8YĢ`ãę× PAėXSˆ)Tâ¤{ĻŪŠ ×o×°™FdŌSŽŽ ęąÎŊBˇ(…BÛĻņ„€ųD ØĪ?ų/Ëû7ō_ū[fCūKųw~”û71¤‡įGšÁéEÕ 0fĮŽÍ!&›ŲĖ™3Ũĩ×^käeøđáö9ŋßzë­æT6lØĐ™Ķķ"Üë„N°Īø;Šž!8„háŦâÔsBx( `\Æ8ā€ĘV‡ÂBĸŠ=d€"Ž8ЧŸ~ēEĻ5ć0-ˆ6ųF¸äģWôw `ĸĐxĸáĪG5ĄF !oA•„ØÄėP•pŪ)æI­™=z¸÷ßßÔlEŲoŋũlū8į.œ|Č)ĩiß}÷‘´cŽ9Æ]sÍ5†ûå—_n$ ØP ŧSŌá`=!1p†4@ GŒaĒķ\°`ŅLRÃņaĮ‚-Ųe(ŠÅBä8ևŸÆFƒ4AâXs ļ÷áP•ėx…đ¸Đ˜;AÁ Lēuëf×!ũr,a}(JĖoĮw4ÅË ōoãĒ="ĘCĄjGVoB@â"PėįŸü—åũų/˜/"˙ĨügC”û71¤g™ĐŠÃ?ÜHN7Î%ę …'L˜`ukpŌQLp2ƒŋŖBŒ;֜W¯|…zöÂėŋ˙ūö9Å$}ÎÆáÁ9gŋ oãéiНôKŌŗgO#„ļ‚…#ŒŗÎ¨÷Ū{Μbœú7ß|͜gԛ ÚA_¨¨R’ũe„qP;‚ųoŗÍ6îöÛo_ÆA3jįų}>8ķĖBã'J’ˇŖF2Å  ûîģ¯9î5OÆPgž}öY›×Žģîj :DÅ7ÖRįęę‹iƒøqņ˛× ĸ^ėÉAõ /ČX&ŠĄ¯°cYwæJ-„›8ŸɓO>i„Šš˛'‰7i()\CŦ‘oT;ˆ&{Ą¸~˛‘ŋŽāöđÙaūÁļmۚrÆuPŒåĄP û4ĻB _ûų'˙åŋŅA˙Fūˆ‡ü—ōīú(÷obH oõQBPJpQiíáM9û8|ÃAE`ßĮā”úßqHų}ŅĸE˛Æ[tŪŽĶøûHšb BĶpt ÃÆQį&ô*ēCōc6†-ėKéͧŠT˜Į‘g ũ 4 gõČ;õôÃQqžūúk#+ú;Ž6¤blĖŖW¯^VÖ ;ƒ-œˆd‹ä8ö„€F…ZDcoás¨ œvsOú&T BÆÛŠ ŠÉ´…uŧīžûl/má…ö“ĐBˆĒYíÚĩMũÉFj ;RQKv°ė)‚Ô āŦˇŪzNˆĒåķažqũ@!ŧ™¤âIŌ¯Ôx˛ÃēNš4ÉŽŽ5sô×-¤ŧĐ-ĘCĄĐļi(öķOūËW7čßČųÃüų/åßųQîßDTTœCÔ ö™\pÁĻ€āTâķ“P,Čęá[„ ąįÆ˙Ž2™Œ°ße"Bčd”0"°ØįÂ^” 1áCŧņĮAe¯ $傰,ÔÂÕ`Ũ5jÔ0§egΜ9F’ŧķNXÖŊ÷ŪkįáxCؘ­´ /ŧЈNøŠūNXÎøŨwßmŠ”o8íÂĩPŠ‚Ŧoėo!teŠ:T+Ę ļAĸØ{„‚ÂĀžߐ‹ëÖ­kĘŠskÚß˙m}ŖŦ@DPAa{ß ‚„åųæq‡DA!hŦafgžyĻũƒ@ŗ‘ÔϰcÁŽln\3ôĪ+Ô¤`øk™CÁAuBCbũÂHßŧ9{öl#m(V\GŲH ëˆjGĒĒķ',­Đ-ĘCĄĐļi(æķOūËōūü—˙&:’˙’Û]åūMŠá žÛáí: 8(D§•ĐX/!D~Ã>oú!%8ÖüŽãLzbHĒ„O- ™Ā‘†ė𖝐%­ ’ƒzÁÛ}6›ãĶØ+ƒúQ@iĐ°Äš ŪđCžØGA_3öÕāđ÷īß߈sđ U„”Ët}´ĸŋ&—™€sĀ BDr„ĖÆūH oK8†Íūl‚§1gœđšsįšķĪØüŽZãD‡đ:lDm %ĩßâ ûž˜?*If ’(˙7ÂüØ×¤OÔ Ū\@˛PĪ M¨q`i‚ČS:CĐŽå€tĄŽ°Î1Ž`ĸˆ äÔņ„ėJ Š× •  5lĀ/“Ô@Z!cüƒ`˛Ö\7¨Y¨]„ēEy(Ú6'„€Č'Å|ūÉųīĘũų/˙-I!˙%ˇģ>Ęũ›Rô!-~¯ˆĪļåá J Ã7§H9Ė[÷āī+‚å—}3\p8¸¨?~=!FAG?ˇ%ŅQ+Braú†*A„ ōFƒõfš öˇĸc}H[æū§āų\?×5ÖX#§…BÃޝ\{… za$4—ķĢâ˜(…ĒO}! â‚@ąŸō_âr%TŊō_ĒĶĖŖÜŋ‰!5ų‡íiŠyķOú^6ėŗ÷¤)x 1_‘ĸ<ԃŠf*„@HÃķĪ—Y˙’†+:]sŒr˙ŠÔd\¤~&œŒ=ė‡ČVÜ1]—”f›tĸ<’>WŲ/„€"–įŸü]÷Ĩˆ@”û7gRÃ>6ÚĢ !<t˙&oÍdąUƒ€žUƒŖzÅ@ Ęũ›3Š!Ĩ1Š›ĸ&„@r u7 *¨!¤&„€Hō_ŌļâšoŠ ÕəÔPīƒZ¤ŋUB 9ÕnúôénäȑÉ1Z– ! Ēų/U¤ēF Ē˙’3Š2dˆĨøŊᆠ<% '„@e öéÆĪ>ûėĘtŖs…€‰D@ūK"—MF ĢŨÅəԀ-é‘IŸë+Ė o! âĀoŋũf…a),Ģ&„€H+ō_ŌēōšwR¨ˆ˙‰ÔP¸pذaîŅGM*F˛[¤ Ã;ˊĨ6mÚ4UķÖd…€AäŋčzÉB "ūK$RTf§ā€’…ŽŦ)C2CAÚ^ŊzĨl暎B`yäŋčĒÉ@ ĸūKdRW\q…›={ļ›8qĸĢYŗf2’•B % ŲļhŅÂę,‰Đ¤dŅ5M! rB@ūKN0é !P*ëŋTˆÔ0ĶĮÜĩlŲŌuėØŅÕĢWĪíļÛnŽ´‰jB@Ō’˛yÖŦYnĈîPČYá—A# !äŋ$`‘dbj¨J˙ĨÂ¤ÆŖ=tčPsĻøˇÖZkšŠS§Ļf!4Q!<đ@÷ķĪ?ۋ…ŨwßŨuÖYq0K6! b€ü—X/ŒKUíŋTšÔ¤ķTLņ˙ũŋ˙įūųįŸTĖU“B@! ’áJuęÔqīŋ˙ž[mĩՒ?!Í RˆÔT žŌ9Y¤ĻtÖR3B@!š4iâ&Ožė9äûŠ–nDjŌŊūeŗŠŅ… „€B@$ |ßm’¤•ˏ­"5ųÁ5qŊŠÔ$nÉd°B@Ô"āU€ÔšÔ^ ˙{A˙¨­ŽįœH.! „€B )UŠ5IYĩüÚ)Ĩ&ŋø&Ļw‘šÄ,• B@!j2UŠ5Šž¤Ôhų—E@¤FW„B@!ŧJã}— Ŗ¤$Ŧ`~l”R“\×ĢHMâ–L ! „€Hũû÷wW\q…ëÔŠ“6lXYø<5چîúôéã:wîœ:\4aįDjt"5ē„€B@¤! ˙%i+–?{Ejō‡mĸzÖC!QË%c…€B@Ŋ”Õ5@@¤F—ƒ”]B@! „@"ĐKŲD.[^ŒŠÉ Ŧņī”Ęģ­Zĩrƒ ríÚĩ+ ?5j”;˙üķŨøņãŲEԄ€B@!WDjâē2…ˇK¤Ļđ˜ĮfĚ5kē•W^Ų­ēęĒnūüųnŊõÖs‹/vüņ‡ûũ÷ßcc§ B@! „@"5ē.<"5)žúöíëēwīî–.]Z†BõęÕ]=\—.]RŒŒĻ.„€B@$‘š$ŦRalŠ) Îą•fɒ%eöÕ¨Qc™˙ĮÖp&„€B@¤‘šÔ_eˆÔ¤üZĒ5RiR~1húB@! †€HMÂ,æŠÔäܤtíÕŠ4IY1Ų)„€B@€€HŽ€HŽ‡ZsõÕWģkŽšF{it=! „€‰A@¤&1K•wCEjōqü ÛŲgœáFŒáASB@! „@ŠIÂ*ÆÆJ“šÁƒģ7ß|Ķ͜9ĶÕĒUËM:ĩ0–k!  <ĐũôĶOn×]wĩįwžB@! DjRąĖ9M˛Â¤æá‡v-[ļ´BõęÕsõëסjB@^*ŧõÖ[îŨwßuŧhxāܑGYxC4ĸB@" RS@°c>T…HÍĨ—^ę>úč#7qâD+Ū¨&„@| îP‹-ėeCŸ>}âc˜,B@!PňÔT1  î.2ЁĐTĢVÍõīß?ÁĶ–éB ô¸ä’KėĨCŋ~ũJ˛šĄB@¤‘šT.{č¤#‘BÎnŋũv÷ĐC A! €!hįœsŽ;âˆ#`­LB@! ‘šhx•ōŅ‘HÍ*ĢŦâ/^ŦŗRž"4ˇ’B`ɒ%níĩ×ļûVM! „@Š! RSj+ZņųäLjØ|gRŖ‹&AĢ*S…@ēuI! „@)" īˇR\ՊÍI¤Ļb¸é,!(ôĐOÔrÉX! „€Č}ŋåT ŠIÁ"kŠB@}]B@!PŠčû­WĩbsJŠųķĪ?Ũ×_íūũīW ­<žõ×_Yī+­´RG)L×āĖø`sĻiėŅĀy†`@¨įÆot?ü° j†ųøņĮMUēųæ›ío8ümÛļuŸ|ō‰ƒķŖ?ū|׸qc#CoHĀļÛnë† f„ˆķģé?ØĀe¯Ŋö˛yBŅC%Á>Č „į…^ĩ‡BsĶM7Y˙ŒMĄGHQ˜í3gÎt-[ļ4{ļÚj+wøá‡Ļ›nēŠ;đĀŨW_}åX3~Æß!K¨Yƒ 2ĀĪ“p€@‚är‰*ĮúúÖˇÜr‹ĢYŗĻÛ˙ũ-ü˙C ×cÍ ŦŸL 5įwžQ ¤*iMũ¤­˜ėB@\Đ÷[.(Ĩã˜T–§üųįŸ7'˙Ã?4UdܸqîŽģî2˛B{衇Ü&›lâŽŋūz#58É8ģ¨ė÷@Ų{īŊmīKíÚĩ-Tiøđáv.jÎ6D RŲ *iÁõÕWíúį†D)‘gaU؋’°ęĒ̚ŖMëÕĢ—ëͧ)C¨8×]w}Āîúõë›D_ ,°*ÆÚsĪ=MMÁi‡­ļÚjeWøūķ°¯5×\Ķ~˛÷ˆ>/žøbûIƒø„Ųqƒĸj-]ēÔÕ¨QÃH d(ėxƆČybÕž}{#PM›6].ô‹=5ĖgōäɎđ4¯š1'Oj 8ˆ%6Ōä2Ø8Bƒm`Į:<ũôĶĻüp dąÎŦ—ķágsįÎ5•âb“ÖôĐOڊÉ^! „€Č}ŋå‚R:ŽIŠ!4 ggŪ7B›Cųå—]›6mĘūY@jPVPYPMPXĻOŸnħŸ0*΃ø†ãL¤åU Ø P()8Ô´… ÚOHA0QN9!\8ü8ŲÁŊ%ôɜ›BĄAŽÖ^{mS¤P.pžĪ:ë,sÖiėOAEÁ!'3™'Uü ˛)€$žG¸Jį@> r´]vŲ%ÔTĨI“&A€„~yĖfûG}äFŽiöĐPdM2 ũARh­[ˇļu˜8qĸŲ ?#Ģa}(nĖBTj 1„îAj Ŧ¨?āņ#ÔÎ' #5~O k‡ē$R“އŖf)„€ņG@¤&ūkT( SAjPLP=pxQ"P& &-Z´0ԁS YAÕ@ą! ˛$5,ûd^ũu# 8û¨!ÂĄļÛn; ĩbJP6Rƒ*€"€Z@¸ĒĘ ŽxŠAĄ@UB Â'”ũ(„šáœžÆžB訓ÃÔ œzö¨@ Ø?YšüōËM•`|úb~žž…ęCXęĖkŦa8I aWaļ` ĸ™ä ÔŦČöl¤r…ĒF(ágŦköūûī‰ƒ|°VAR%| Õ Ę\°›=3žAØÖ_ũ2"I†5”°â¸ ŠÉ´A¤ĻP##„€B "5Ņđ*åŖSAjPcxĢĪv6ėã0ķöŸP/œ}öLāøCR؀î÷×āôûš+\|Î~ vŸŽ˜ 턈vECŊa¤’ƒ”ŲPWH\€’€b€€SŊÅ[Øž d 5Gœũ8ō¨C(/„{ĄĻ⌺w„/’ \ ˜Ō™D_|ņ…9ü ž˜Ī)§œbĘP°Ąrø}F(U„áAւ¤5(ĖÂî Pā‡Ŋ`YČf;ënCc¯x˛Ÿ‡ŦmŊņãĮ—íŠaã?„ĸÅ< `$Ĩ3{™˙wŪ1bH¨ŠŠßĮäįÉū(}ąŪ'žxĸ…ļ­žúę†?á{\ ™6ˆÔ”ō#PsB@$# R“äÕĢZÛSAjųä2ģY+>§† ÅKÁ˜ëƒ‚ώŪzĢ‘0L W~îŦH)dJŸ† ZOę ÅšéĄįՑmB@!PQôũVQäJīŧ’%5_|ąÕŽšöÚkxđįƒJC!ƝvÚÉ 1R(“ĸšlÛļ­9¨8Ú8ĪüŖĀ%āĐ%o wūŠĄ6KĪž=ŨėŲŗ­īŠS§ē:˜*D˜o8娐ģīžÛŽƒDė¸ãŽV¤“â“§Ÿ~ēũاŽ}ûöĩ0<æÎõIsĶC?ÎĢSúļQ—ûYM!|‘ė|ô­>“ƒ@I’š¯žúĘČ8Î:•į=ôPS¨6sËvTČÆõ×_īžzę)sÖ9†ũ"€ŧíļÛܔ)SLE|@,h„ąņÖ%õÅ~ŸŒ€˙ûBžß˙ŊŠE8Į‡Dĩ AjPZ CžOÎģúęĢCm;vŦė^wŨuĐ0WÔ œyÔ–ZĩjŲOH…$ãŊ÷ŪsuęÔ)ģ2)–Éŧ ŠģîēËũōË/îüķĪwuëÖ5")¨^ŊēŲúčŖąÃ†ŅŖG‘cū(Zƒ/^løBV˙WTI°1”*֕=E„ĸņŧ Ũ%pE…Úb‹-l ˛´ūú뛆I°TÂÕ¸|kĐ Ûc=bws ŌC?ÖËSōÆéú+ų%Ö…@ŅĐķĨhĐĮnā’$5„]Ą°įŊ4{îš§9Ä8ž¨ /ŋü˛9øüp¯Ö­[ÛÆ{öĨĐPbh8ą8áŧ‰Į‘ŋå–[ėm>!hėa $ 5"Ųņ{F 8ë„ZwŪyvĖ… MU€4Ą> ô@p´qÖQë ŗũ18å'”ÆķŲĮ˜įŖFąbyáwˆáXŪ”™C9Ä"FÆ1ČH“&MlŽ<°ĩâƒŗmāĀžÆb|öAˆ …‹-*#NŖløb/ö†ĘAŌ°GUķsãķ ’vØŌ„JÃ:UĢVÍČՖ[nikČēzåՅĪŧŨƒ‚.Œ‘A…ƒ˛/‡u%1 “āÜųū ™#ü \×YgØŨÜ"5ą^’T'§#UË­É ‚" įKAáŽõ`%IjØÜĪūŪôã /]ēÔXT HûZpČßzë-stqÔų;YÃØ‹Ãū RūâđķwÎÁéÆĄfo áKyH¤‰c Yã6Ŋ?q Ŧ‰ŋAPh°‡ũ)ˆÎ7Ø`SnØ?‚Ę‚bfdāčŖv÷Üs)8ÔdcS= ƒ>I_ũŅG™=¨ 2>ķö`3ĒsC AũAĄb}Ō7á{ė΁ ’Č•‚ˆšÃX„l1—Į{ĖČûYP]P;Ā0žė<0gö2A`ĀĨËĪmîÜšF>X'ĮĄJŅ?ĘįĐØßA(kö0~øáevŖŌqî|`d„=4Ø@ŠaŠŲ0a|時ŒA˛hŦ* ĘMܛúq_ĄŌļO×_i¯¯f'Љ€ž/ÅD?^c—$Šb}ö›đ&߈D`cšĪLÆ^ –ø;ąđ-€Ė `O Ē8Ž4IP„h&2Ēąˇ‚M÷aŊ6„q“pŸ6…s6v˜-lÚgŸĖŠN>7:{x„ōGãŊÚ9Ę´“=8œ%r6|!4ö,8į27Č!z¨1åĩlvs}@ú fžeÃ$ŗŋŪ(Rų,>ZŪÜĸü]ũ(héØĒF@×_U#Ēū„€đčųĸkĄėZø'Į”IŋhP, &(.—( g‡%ō`/Lf ã(}ęX!PH’~˙+UõčúĢzLÕŖ˙E@Ī] Š#5„WąwÄo–z nEø![„›Ą(¨ ¤  ‡~RVĒ4íÔõWšëĒY 8  įKV!6”løY<ā•B 衏uHĢēūŌēōšˇČ?zžä㤌 R“”•Ǥ„Đą‡›ŋT‘”>šCŠÎąĸķŌCŋĸČéŧĒ@@×_U ¨>„€C@Ī]‘š<\ d#ƒ)‘‹ŲØ÷CV/˛ĩ‘Š˜z/„ÎUĻįFļ6˛ÆQ&J#!E<ŠÉSŅ>2Įc¯×HĄí8Dą)ėØLü¨ßC:î$6=ô“¸jĨcŗŽŋŌYKÍDÄ =_âļ"ÅŗG¤&ؓ>˜´Ã8îÅlíÛˇˇ}D:uĒ2RœãČŪļÃ;DšæĮėvÛm7ĢŗSŅ>2„‘*Û×ljdP–ƒƒøÍš5ËmĩÕVËel̊q ҇ú…@YcdC@×ŸŽ ! ō…€ž/ųB6yũ&–ÔfDŊę†Pŋ„J÷¤l8p 9Úŧ¤¨"Å}›={ļ}ƆŪ¸÷īßßTŒ`CE8ëŦŗŦū õIŽģî:ËrFmjąĐƌãpĖ)’IņLԊ 7ÜЊ2R‡ĸšÔČéŌĨ‹â ŗ“žÔ8!dŠ2R =ڊI2Ff:fwęĐ|úé§VE‚ôČaļA4ČøFMú¤X$sfî4jéuÔQVÛģI¤@-š›ožŲŠ\öë×ĪQė“ĪûN9å”eæaš4i’Ĩ¸f.šäŗ‡¤āÂīôM RV_vŲeVŸ‡:2>%›õx|ÔęĐĄƒŖ8čqĮgũa ĩoęÕĢįîŋ˙~K+MßÔōBšÔÂÎĢŽēĘŌ=oļŲfRBŖ& c°ū¨U¤sF]âz5j”[mĩÕŦ˜(ũ’âšē<¤›âĮZuíÚÕė⚠ā**¸đr†2‡ÔŊažÔB!‹CĶC?̐^tũĨwí5s!oô|É7ÂÉé?ą¤į5dȐ!æxCVųęŅŖ‡‘ĘæÍ›/S7†đ¤F™##Žc Q6ú}îšįÜíˇßî&L˜`ũāLãėúė׌ID'—*ôČJ}Nž<؊X⠇ŲÉg-[ļtãÆŗtŅÆÄĄg­Zĩ2Ûļm[fõq(ö‚@š6mjÄ Ė6æ áXˆEŨē˙ŸŊ;ˇo*˙žĶ¤)Ҥh"24đ+$%А!dŠ’)ķLŠ’DŅdŦLŅ RB™§šG*MŌøŊW˙õm}î=ûÜ3ė}îŗ^¯ûúŪīšûŦŊÖg­õŦįķ<ĪZĪR)÷Œöx'/Ō7ۘœ‚p gĐĪ~öŗb—]vIũ@HäûŅ77Āųnîb ą'ø„'wõą6–vgX(ëęäĩĄP#’„2Qƅ=QąIņÜ}÷Ũ‰čøéÖ΋/ž¸ŠA(œ3q–ˆÂ/|k5ÖHĘtUÛŽ-ˇÜ2ņҧōígeoO׆n˜žŊâŠ+RČ<÷ØcŽ¤&÷-“„ƒ§áāar†åˆ#ŽH¯mļŲ&ũx^™Ô 0G„œˇņ„a¨j'%ŸâÎÛ¤X”eO÷ëĘExīâ&üI¨›žĶ÷Îļ!5Hs:p)đ ceRƒČĀÅų8ō¸ŧå-oIDˆwĘųE˙r[s߄Ęå+‘+sá…NgUāÆK“=Iˆ \\N€xēŌŽ\‡2DÚÂ#„tųnn;˛Áë3•§&BÖžŧK.šdŠ)cžõÖ['O Bįŋ3@.VĻĮ[։RƒäđøsÍ÷xüŒK™Ô…3ÆáŠ aüWžEøYĖ„@ !_†j;ël-ЎP%–{Ęd9|jĒĄÎÄŗ!´‰÷†‡ÅŲrq–F ~š8{áģÂŅĘÅĄráJBšĘ$ÄīęŨúĀã ]Ú-ĖKŋį™gžt.¤ĒmČĮû4UAtāčŲé ‚•ûV~–bī= ,°Āœ)ūž‡ x7´Ķá~Ąaīscšžg<ã—éÚUūģwš2ڙĄ^‹ķ;ˆ,lsŠÂræ2cËCՆBŋ Ŗ4šmŒų7šc= ƍ@ȗq@sŪßjRĶŖ%@ŗĄßėņ™ôÖÅü›ôŽūãC äËø°oڛƒÔ4mDĸ=ĀĄ?PŖĘžˆų×3Tņ` ÔD äKMĀ&øņžIŗ#ōˇD ö!ëˇ}c6I-Ĩc’F3ú4 /͏qļĻgRã@u>(=ÎĮģ@ .Np}õu×]Wī‹ņt 0 BéQM </1)2=“šˇŊíméF/7EE ö đ‰O|"]í:ë(Ā8Ĩc¨Į;Ų@ȗŲ1ÎŊô˛gRsÜqĮ?ũéOSŽŽ(@ Đ\›Ŋä’K;ė°C{-(B阨áŒÎB äKŖ†cŦé™ÔhĨ+l]ŅûˆGē×wÄs@ 0™ų曯8ôĐCĮđöxe đ?BéˆŲÃB ä˰m_ŊĩI.îšįžÅ-ˇÜRœsÎ9ÅŧķÎÛž^G‹ F@ČŲzë­WŦ°Â Ah&xœÛÔĩP:Ú4ZŅÖ@ ]„|i×x ŗĩ}‘ ēā‚ Šõ×_ŋØvÛm‹Ĩ—^ēXnšå ×>G Ņ#āÚfW6ß|ķÍÅI'Tœ{îšr6úaˆ7vA ”Ž˜@ 0,Bž ŲöÕÛ7ŠÉ]=ūøã“2ågÁ,.ŊôŌöĄ-ZŒĀĒĢŽZÜwß}ɰ°üōËÛoŋ}‹{MŸDBé˜ÄQ>Í@ äK3ÆĄ ­˜1ŠiB'ĸ 3G „ÂĖ1Œ@ /13@`X„|˛íĢ7HMûÆl(-Ą0XŖŌ@ (Š"äKLƒ@ !_†…lûę RĶž1J‹C( Ö¨4‚Ôā!"úËÁmYÕAjZ6`Ãjn…a!õ@ȗ˜@ 0,Bž ŲöÕ¤Ļ}c6”‡P ŦQi „§&æ@  Đ_†nËĒR͞VsC( Ų¨7BžÄa!ōeXČļ¯Ū 5íŗĄ´8„ÂP`J@ <51@`ˆ„ū2Dp[Vuš– ذšBaXČFŊ@ ō%æ@  /ÃBļ}õŠiߘ ĨÅ!†kTᩉ9CD ô—!‚Û˛ĒƒÔ´l†ÕÜ ÃB6ę /1@`X„|˛íĢ7HMûÆl(-Ą0XŖŌ@ OMˁ@ "Ąŋ ܖU¤Ļe6¨æ~ũë_/6Ø`ƒâØc-6ß|ķ9ŋO>ųäbįw.Î:ëŦâÕ¯~õ ^õĀ,F ”ŽY<øŅõ@`Č„|2Ā-Ē>HM‹kĐMwŪy‹‡=ėaÅŖõ¨âž{î)ZhĄâūûī/ūņ<đĀ _õĀ,E ”ŽY:đŅí@`„|Č-yEš– Ô0šųŪ÷žˇ8ā€Šŋ˙ũīsĒÄ#QxāÅ^{í5ŒWF@ 0 Ĩczt9!_Ft ^¤Ļƒ4Ė&ōŌüío›ķŠG>ō‘sũ˜īŽē@`v JĮėįče 0BžŒõfž3HM3Įed­*{kÂK32ØãEĀŦB ”ŽY5ÜŅŲ@`¤„|)܍~YšFĪh—Ŋ5áĨ Ūņ–@`ļ!JĮlņčo 0:BžŒëĻŋ)HMĶGhíã­Ų˙ũ‹ƒ:(ÎԌīxE 0ÛĨcļxô7!_F‡uĶߤĻé#4‚öšílë­ˇ.N:é¤BZ”@ ‰@(ƒD3ę 2!_b>dfLj>üá×]w]qà 7}ėc‹K/Ŋ4Đ "°ęĒĢøÃŠŊčEég§váۇûǐ/ÃÅ7jĻC äËtÅ߁@ _-_ú&5_øÂŠõ×_?%j\zéĨ‹e—]6ũD Ņ#pũõ×ßûŪ÷ DāÜsĪ-^÷ē׍ž!zcȗÕ@ äË@Œ*@ AʗžHÍnģíVÜqĮÅ9įœ“’7F æ īĐz뭗Œ GqDsÖcKBžôT<Œ/c=^Ėf*_j“ Į<ķĖSŧī}ī›%G7v"°ëŽģ&ŖÃ‘Gؚ„|iÍPECg9!_fųˆîCD _ųR‹Ô ųÄ'>QœūųCėJTƒB@ڎ;îXŦĩÖZƒĒrhõ„|´Qq 0Bž Ö¨4Š"…Đ×Õ_j‘š‡?üáÅũ÷ß!g1Ũ– đˇŋũ­xÜã—ÖmĶKČ—ĻP´/˜/1#@`Xô#_z&5˙ô§?-Ž>účaĩ?ę ! đŽwŊĢXrÉ%‹vØaĩĻʐ/ƒÁ1j F@ȗQ#ī fuåKΤfË-ˇ,VYe•b‹-ļ˜=hFO @@Čč•W^Y|üãoloBž4vhĸaĀ”„|‰ ÃB Ž|é™ÔČņŠO}*ŽmÖČEŊĀpÕķV[m•ōI5ĩ„|ięČDģŠų3$†…@]ųŌ3ŠYmĩՊo|ãÃjwÔCD éëˇéíâĐDՁ@ëhúúmzûZ?ĸĀ¨ŗ~{&5yČCŠ˙üį?ClvTÃB éëˇéíÖ¸DŊĀ$ ĐôõÛôöMˆ>ÃB Îú R3ŦQˆz!PG(ŒŖŲMoß80‰wmA éëˇéíkË8G;q PgũŠĮÅ;#PG(Œ¸iéuMoß80‰wmA éëˇéíkË8G;q PgũŠĮ5ėŋųÍoŠüãÅĶžö´Ą´ė¯ũkēüĪxFņčG?z(ī¨[éôĄkVĄ0°—Ö¨¨éíĢŅ•xt„üķŸ˙,Ŧåųæ›o„oWu"ĐôõÛôöŌ-!7F‹÷LßVgũļ†Ô|ík_KĘįrË-W”˙ŋ˙ûŋbˇŨv+ŪøÆ7ΡŽß÷ž×ŧæ5ÅÉ'Ÿ\lļŲféšßũîwÅžđ„â‡?üaņŦg=+}öī˙ģxėc[œūųŞ{îY\}õÕĒS†ÔM7Ũ´8äCŠī}ī{sū~ķÍ7/|á ā˛Ë.Kīë,ģė˛KĘ5˛ÄKûīŋqĐAÍyäIOzRņųĪžp§wˇ÷~đƒLųJūđ‡? .¸`qņÅoûۋģīžģøĶŸūTŧō•¯,<Ŗštö}¯Ŋö*ŪûŪ÷Vb…-ēčĸŧ?ũébÍ5×,~ųË_[oŊuqá… ,°@zĮ6ÛlSüņ)Ģk|= K "˙?ãŒ3Ōw+'žxbąíļÛÎųŗēĩМ‡åˇŋũmÂiũõ×OˇöåōĮ?ūąĐm(ˇI ?ûŲĪĻ9CvÄG¤ēeģ˙ú×ŋ^~øájܗZjŠ)ĮeņÅ/Yd‘šÚ o×,gReNhĮ}÷Ũ—ž3Ūå1žÉ¯#fōž~ŋÛôöõÛ¯A}Īŧ$^ûÚ×ΊŌŨhŖæZ ƒz_Sęųō—ŋ\ÜxãÅŪ{īũ &}ôŖ-ļß~ûbÕUWú%6YîŨyįIFD™Ļ¯ßώ¯-ķiäPȍļĖļ˙ĩŗÎúm ŠyŲË^VėŗĪ>Åë_˙úĸüģ īŲĪ~öĐ,u˙ûߋĨ—^: úa‡–žčĸ‹ŠõÖ[/) Wî¸ãŽD(éŦė˙ú×ŋŠŗĪ>;)Å×\sMz摏|dąīžû”ërŪĘŧ„×^{mz‡~ųšPÂmōo~훓BŽŒ¨īWŋúU˛ė# ˆUˇ÷RĖ8ā€âļÛn+\“'īĐg>ķ™ŅŌ$ƒĐ:á„ŌkĢúŽtąŒ",ČĪ÷ŋ˙ũâÉO~r1Ī<ķ¤÷?õŠO-x~Š<ĸĄŋķĪ?ņíoģxųË_^œtŌI KdđĮ?ūqqÜqĮ;î¸cz§į)ų|pוįģO|âĶ3ڈŧûŨī.ūō—ŋĖņyæ‹_übņô§?Ŋ¸ęĒĢæÔeĖdŽ×GDę[ßúVRŠ~ņ‹_¤> eÆŽØ 7ܐęŅ—ÕW_=Íŋ7ŪxŽļ=ūņ/Î;īŧ)Įå›ßüfņļˇŊ-õ×ņŊõ­o-ž˙üį§1đũ#<˛8įœsЧ<å)‰Ô!ļÆxž¤:Ba"¯éí&åwN‚2҆ŒÖˇ5“ålŽį¯xEZŸįž{nņĒWŊǟę{ūÎ{Ūķž´>É6ō.JšŲ8&A…ÜhßĖ­ŖŒÔœzęŠÉZÎ[ÁÍ*Í%ČS@Ų÷ģ„bšÉ&›$ë=°üōË'%ĐīguVR˜)ëī|į;“"ÍËaãĄ8ŗjūüį?OžÉ7Ø`ƒâÖ[oMßûķŸ˙œžë÷•VZ)–ũÎō| ˜W\1ĩIQJú—žôĨ9_aáΤ!čŨ÷Ū{ī\Ū‚ŋøÅŠ_~rĄÔSĒĩƒâLĄŨi§Ôõ!Xgžyf!)Ņ›ŪôϤtë "4Õ{)ū™L­ĩÖZÅæ›o^l¸á†sžÃ['ŪĨ[ßũÍ;‘\x¨ŪņŽw$‚€€^rÉ%ÅW\1—2ō–ˇŧĨxĖc“< <]ÚÃüāéše–YĻ8ôĐC‹ĩ×^ģëĘ{Îsž“ŒėûČG>’æ’ū›HŒīû‘Đgå;ßųN˛jû;Ō’‹į(Eˇß~{ōdG⟠IDATt™Cę.÷ÉO~22ķ’ëEN•Î2Ũ¸ `æĪį>÷š9_}ßûŪW}ôŅ CGm8đĀĶß˙öˇŋĨ9lŽĖ;īŧ3–Bu„ÂŒ_ÖGMo_]čWĻS&Ė#ĘŋįvŪyį$ëöÛoŋ$/^xáôģyMƚsäKŪ(_ųĘWŠSN9%=o˜{>C˛ÉŌcŽ9fŽ\f 7ÍaÆ Ī1ƐŊdĀvÛmW<ęQšƒį­ŪĨ¯~õĢÉøĀûbŪ˙ũéw2ŪīŪEZ¯dŅ]wŨ• /eYk=‘ĩ Cˆ5üą},ĩ‡áāĨ/}i2–Ø/Č{Â6‘}ŒdøkŦQŧä%/IƆ†˜đŌōʲęō°2"‘gAjǧuĶ×oĶÛ7Pa1ÄĘÚ.‡:åÆQGU){ôķ oxCŌQDz ĸô˛5äÆ'YEÕuÖīXH ¯eÚĻesĩ9ūčG?J„blĶõ7 )Ģ9¯e˜Ōɓ‘gÕļYąr›„<žëĮsōęP…h ˆRCéļšķظmöŦoB Ęå×ŋūuņÜį>7…sQö)âŧŠī¯°Â ‰Lä˛ëŽģ&läâũÂÍlōYYĩą"I”…\l˜6fĪ ƒIkķ …wŨu×MũĄ4°P" ”Ąh÷ÜsORėģŊ×į<\0E !ũëvncĒžĢ‹Bˆ”•t8R((?”¨K/Ŋ4IšPüP žûŨī&¯‹ą‚'åá1Š*ŋ˙ũī žʐz´“BeŒ|Ÿ—J?):°BTŗ"BĄ2w2iëŦ_ŸScHšBFå–[nI;ø•I†qõųtãBQ{õĢ_H\.ÆûØcMsĘįæ­đJ}[gu*ÉSŋĸ¤ŽPč÷3ų^ĶÛ7“ž âģS)+¯ŧršƒd€õ@i'˙Ȕ}čCiSļ>„Î’ŗYųgX˛q32(žkŨ1  Œ'dâB#@,ŒHæŽz=cMō˜đ<2vĩˆH.ä´9m}[3Ȃu/|—ŧ$‡y@õĶ:°~Õa­^~ųå‰lņlæB^ëxÄ#R]dåķž÷ŧÔVm¤Ŧ #֖öxC–ŗ7ŪĄö›ėõ>ō šqĻ –‘įxrŗl RS=››ž~›ŪžAȈQÔŅv9Ô)7Ŧį*Ųcou"†îGNŅ%„ˆ y š1ŠŲöŋwÔYŋc!5Ŧ€Ŧc”sŸŗ”H“ČgŦhŠÍ§EȕÉHļåß)Å‚(Ēž#ü‰UÅ’w…'ÂĻžĪg°ŧą–Ÿ~úéiãCnlš”€g>ķ™Ĩ-ļØ"Mæ=öØ#) Xžs#ŧU‹›2mP|–R °ūÚlJO "–Ã)´™Ņ6át6až›\|ßįęC&(Û6rĘ6/…@{ģŊ—å™âipÆēā‚ æę3ëdnĶT}÷% :EŊŦ¤ ĪĘĘRg^#ũ¤4!!Ū`RV(ūÆÂĖRöūtŠą§vÚiéOÂŗŒĢqtŽIŸzœ?šFņa‘66Ŋ[.šßú„LŗzĢŌg\íd‰î$žˆ97Õ¸P&ĩ2é\Ė+sÄUwũŖRäx,ˇÚj́H:Ba /ŦYIĶÛWŗ;œl$לUËI7?(l¸ČõĮ€C>ųŨš§”+< 3<˛ÂH;Ã[=C‘įõ™ˆ4X/æ%rd:[F.ōœ+äyJA0×ŊŸ1¨\2ŠąnyŪsXÁsoä+c™—×!bÁ[Ũ-üė/xA")ŪOŲ`Ü!}Žũŧ?äe*RCf{9Ē­ŒFęËg&-ŦÉ 5ÕSģéëˇéí¸ĀR…m—C`)Ë 2´›ėĄ_MEjBn i’UT[gũŽ…Ôh3ĨÖĻHųdŊ§´ŗ–吂Ü/›ĨWX’Đ ĪæßMNÖB–E˙ÚxĪ OËÖõŨwß=}n3ĩAÛ(m˜<=؜XÆ)ÛB&<“wŽÁ÷MHĘŦ’oĻō ō`cÉRGAeA¤tSÜŒg‰ķ}īaŊd…Ėž$ŠmUbÍ×.J…ČŦŧ2ú$4‚u´zfSdm/“›Ŗ°‹˛rĖROĄ°ŪÛč;•Y ĩķ6ûėaŅ'Š‹’=H~¯zo™LåÍ[ŸŨÚEÁæŅ °(Ķõ]ˆÁPVŌ3¤ˆ3Æ,ģŧe°Ķ?¤Rˆ‡ąC0ô7{M˛%”bRöūtNo …D¨Ō¤^Šž8}ũĸt°¸–Ɂ>"ŠÂSxY¨%e‡2„¸¸ÕH¨ Ë3o™yˆŧę¯U&Ō™äĒß@Rœ}™j\œÛrVQEũËĘÃãdÎRV)¨æ¸Â:L‘ÔžA€Ž#F'Ēū÷Ļώo˜”ßiūX÷Ö<ĨŽJvšO Œˆ9ųį<͐]kŸL1÷y=׍ÔđTōjĒ›Åŧ´žÉIäœWˇž… ņ|ōü ũd8qųOŠ33å[§"5äÓĩ ˜L%ŦãėŅŅ.rŠ\Ęʉ0V˛„§EĖ;…‰Aöėŧ6ä=YmũÛKĻ"5p´.ĩ…lŌWûCš 5ã–ã|ÛåP'Šahí&{pxĨíīdĻß§#5!7†3;ëč#'5ēœ' +"bbÂØ0yXÅĻÆÂÎÃAĄ¤,S4ķīŦžGŽXúl:Ŧ˙Ȋ:m^6tD‰Ō*&ĄaQdYgą¤Ä*ž‹ŧØŧ˛EŸRžįa˛‰æXoŖÍ“§XØü§ōy íŖ<¨+ ›E0Ę>Ģ<+iVî;§îP›ŗâF Šēŗ9ŲKåķĒ÷ cBđ)^ī„{žÖX›„oņœM×w–^–Íō•ÔBĩˆėaʖŌė™āÅ"x$ÄŦ#’åÛʄs–Õ˛ĮĸÄŪš˜C,Δ,ãÃc–Ã=ƒ a¤ĀäĐ5ʉB B$Ė }2~”´\PJ¤~"aEßĖ7Š×Tã’ĪËäī#,åsVŧ‚ŧp1wÍ!äÖ ĒÔ ƒzgzšŪž:}ÆŗÂÅÜzˆÄį5… Pā4Č@ÖD$ÄüG"ŦōƜöģ9ÆXb›ĪU×$“ ÎĮä+Ōŏ›˙ŧ˛ īšÜ ܈YŒT-ÖŖ:xprɤ&…ČN2ŲBô‘(ōYą–<ĮጛđbĨ3œÕz'_õĪßôËE;ÉŲl$€™Ī#Ä{åībãÎ*m†§ļ2€ņ–"lYV VqĨsõėnúúmzû†!3†QgÛåLĘrC”F7Ų“ GžÃ $ʄžäXDȍaĖŽîuÖYŋc!56 Dƒ‚j-–5–ōō5ļBĻl”ŧåß§‚••NåŅĻ°0æk”mĻ&´0ĩōŲ•:C…hQ˜’ōmZuęįŗBđā„`°0 ē+D”õĩsœģŊ åîí,&ĩ3;3-æō =ŊļiĻīėõû°ĸlâ į*ü:•Â^Û5Šįę­Q´§ŠīāĨœ;KØ)ˇ„Æō”tΞXë›f…bLđÆ0L¨›üķįØČ“~ /™! pšo)ëļʰĐųFø v9Õ3æ>yĮ°SGŪķR‘MIÜŽŖøNĶ×oĶÛ7Š1ä;&Mu“=ä#h]]!äÆ gÛŖbzÕ_ÆBjÛŨęÚ˛ÅQˆ8k“V¸SųFąQ´#ŪQ¤ļ|Ū(›Ō4ĖĢõZŲž§ë…qôŽéí&ņÎ@ -4}ũ6Ŋ}mįhg 0ęŦ߉%5€gą6!¤G´Û†W~ųL\ėĘŋ^˨HMšĪŊļmēį7ĖšVēn RSą™=_G(ĖėMũ}ģéíëEép›Ŗ¤ļōP šÔ¸aĸB#ōßoõ#Ķfōž^žKé ŗ]žK&5ŽæI#ß}&ŋŽÂkÆ{ϏēŨYË,ķŨĒéēk—‘ EžcŦqsfäĨéeDęŨNÔ[ƒ}*äËÔx†|ų>!_ģöQ[õÛ*RÃZ)‘ڏüãto¸ë™m`ō%Čx-9’ÍJÂDy(ōōĮČCPąŅI )šĄŧ0’8z†vŋũöKš\ęw–Wš(nå’Ô͕Ĩūõ.ÖBųäˑ æQzTĒWœĒē\9ĘÂ+ Ĩ|,ō °:vzj$yâÁQäo‘¤Ôŗ<0DúŽ|?ōĘØ”%”KĄøØ˜ËžšÜįœO•֗]vY S?oƎ;î8Įk%§|)°Įį†nHÖP‰/™Lj\Ÿ*§‹4ūÖ ˙éÚ¨íH’:\5-˙‹$ŦŽŠ•?héĨ—Ny+ŒĢ$ÆÆ5˛r^č‹F™v’,WÅRt\éĒ~ØIĀ÷Å/~1]!ËēëĻ<šlĩ\\=^Õ÷níņžĒqÄžIu„ÂLŪĶīwGŲ>Wtš3’Yšģ’¯ÉgŌm}YĶ”]kÍ\Q$a-{jä~!xpĖ)Æ s—,đŽ3Î8#åm™nžRĻy ŦÅ˙­ykÔúq5}UŽ5§É(a§äšŧW<Đp@’ 9ߋz;e"Fą'Č'OË2Íģ;ė°´­ ‰kĩ“ÄgÖ=š´Ųf›Ĩĩ%Žžķ„ČĪ…ˆø>ĸ;}‚mšTÉŊÎ95•ŌAf(2ZUyËô ųĖ{^Žõæ„ĄörņYsÍ5į4Ė7\3­oæQ•ü‘‡ė”{Gb^2YŸåė"›ÉuûČ"‹,R[îuÃrēŊ¯ßõ9Ũ÷Fš~§kKÕßGŲž/!_BžôŗJģ§Îúm Šą!Č;@ņ %T<#<žōДIŦšBɑlOâ=XÎ ãˆI(ČzKÔ¯rņ÷ĒžwkyP5î”Ũq–:BaíeûŦekĖ<’Ô֚6˙‘‘ĒõEŲ5WĖĘŦõ"qd™ÔøÜšD<Œ5EŠ1ˇhsČ{§›¯,Ļd˜„—B ŧ×͍ę–XR8Xˇ:ųœ˜Q…ĸî˙ršÛå°ØL²L#vx?Ŧ%^'ë°,Ķ#ë]{$Ē$“–_~ųdhAŦ}É3{C[åCôËēÕ/ ‚ËsŽ›ÜcŦ(ãĄ/å|<ã1ẍ́Ԑ]Hœ\;öx—g’ÉØÃäYdˇJū0„ĀŦÕwķ–ú'ŋdųŦ#÷äņĒÂ&Ķí}ÃZÛŖ\ŋũôa”í ųR¤š]֙BžėtŖ/ũŦŪzžā֐PPHeœ§xSHÖBŪ„ĄHP6„”ŲT”ķĪ?ŋxĘSž2'éĻĪĘįKXŨrø–吕QąaqÄIņĄ´¨[a‰A„XãXûlčW]uÕ\ĄŨ겥!Yk›§‡NRãŦĪ-ˇÜ’˛t{á ŗĩdŸøĀæäqÂÉs„ĐQō䄅p*R3\…CYĸaS´‘ÛˆyK` éßũ›ÛĖÚJŌ&ž.JĄīwÃ_ÖŪéÚČēAą‚Ĩ"Î}›mļI AÉBā`‚Di e€‡ÍØ#ąMķƒ˜ RŖ_9ƒ:‹6Ī’į‘BÉ8JVؚ-ÁXˇž¯˛Ę*jĢpˇqG4ĮYFšŠ÷ĶĪQļΏ“#Č9ƒÅۚļĒÖšã;GuTęĨ–§ˇķLMgø™ŗzŦųÎÚ ŦūŊĖWsqÂ|ä-ģ‰ÂOÉVOˇ:ü}ŪyįM2ÅāuÔ'Ūd 3Ëgé(YĻ!(Ë-ˇ\Ē_ ŲĄßåđŗlĨ"5Ö-’“e+™ -d1#ŒúāFnÛ RĄtŨäžõÚIjŧΚÎÅ˙“fBjČXÆA°f_šĀŸŧá™ĪF˜*ųƒÜy䑉XŠ$–|ûíˇ'Rã{ŧi Gä"cLšGÎTaŲËŪ×ĪÚėå;Ŗ\ŋŊ´§ķ™Qļ/äËIMȗ˙zÛr äK?+÷ŋߊŗ~[CjX0m0Tʴ͚%,“šŧQpųS†ypra9Ĩ„äŌÔ°ÚÜ…\X<…ąîŗĪ>éãŧÉōĻëâ•é$5Ũę˛ÁŲđ(6q9t(XeO eD(”ž ëØwß}“uāŠČŪŦß÷xsX‘ķ!â| x*R“ąĶĘXãiâyá%qî…R@d|)+ŪGIŌí´ŅŗČ˛JvÃ霮 ŗĪ>{NX\ƒLjxĸ(T6 o kíá‡>Wļqaå Āæ PD…B@aáé‚ŋ¤†b˛”K&OU}įŠëleŖÛ¸Įq–:BaíeûXÆyô„‘ ˆh&5UëËÚĻ@Û¨¤ƒ šŽÔäЧŦØ3Äô2_YŦ}ŠŽ5“IŌÁ‹Ú­F ¤†×Âüļ.˛A&­žd™Æ0C^æĐ+Ī $ÖK7RӁ@dO Ī3Y™ekî;ÂHŪ‘EŒ%úBvÄ+‡vu“{U¤Ļۙš™šrøYˇ5@Ö!ž 0pí6äĻgô°DæČ/ÆQÕ7Ūū:rļUXÂoēŊoXk{”ëˇŸ>Œ˛}!_ūKjBžĖMjBžôŗr'˜ÔPē…b8“BąæMYc5Djl”}ÖwžV2VĪn¤†ōî‡G„5ō`Ãâ ¸R,ōĩ™P|oŊõÖdi¤ü{ŇõŲe—MŸuĢK} o ĨÉéôÔđ(Ąĸxąöō:dOM•Ō%d†7ęŽ;î(]tŅdũC<ú!59ԎRƒL(:‚Ú{ÄÉSÎô9ŸŠĄČŦļÚjéũˆFūÂĪzmãO~ō“tКåágĪSSE"U1ũ°ˇĄ ë`iĻØå‚Ô˜7*„qõo'Oš`Qī,ŨúŪ­=ŨÆ]ˆÎ8Ë(7õ~ú9Ęö!î<€~Ŧķz*RcŪ" ”pgĻ„S!ŌU¤†˛m –/ ({+z™¯,Ŋę gōŲ1íĖágŨę€ģp&…ˆ‚oN#-ˇ;–•ōÔ:ãĩŌ7á“ŧÄTYĻÁÍÚV¯°:xôJj´ƒD(ī“ĩMF•‰T7š×TRįncÁØŖ?ŒŒ^æ ãO= ÂüœMîlžô*÷ŌǰDΧÛûúY›Ŋ|g”롗öt>3Ęö…|™›Ô„|yđ™Ŋ^Œ&!_ū‡RõÛO e‚g@”°#á Ȇs'Ɯī…÷C,ģŗ “0ŠÆ ,PIj„°0 !@€x(ėwŨuWō<đPXŪrø’ Ũģ)H‰ÍŸâ‚ŠÃfĪb_U…Đ6 ˆžP:ĘqãČBÅ (L‡ É.†TäŽâŌyjļ|žG?áAq˜ŠÔ8đ,Ö_áĩÉmČÖGÖCũTXyĨ\xÚĖËW:S~~ūퟧ°ŧnøO×FīŌ&–Pũ€‹0R‡D°xÚ0lū,ė,–ˆŒv:¤mbūË!dęD\(ųpJ†ĸ/ÎH ˜ÎŌ­īŨÚÃ#U5îũlăüNĄ0Č÷öZ×(ÛĮXƒaŪGëM8(ųRĩžx+Ŧsʨy‰čōô• %yž9—C~ëôÔXŊĖWuņ– ŧûîģA/_éâw¤qô/1cšŗPuå^–dât{_¯ëąîsŖ\ŋuÛæųQļ/äËIMYg ų2÷E$!_ę­â:ëˇ5¤&C@qf9ĨėŗFZ8UIđlū^>[Ņ F„IÉÖ}ŠŠúmŪŨŠ ŪæĘÚČ{ãyßį@v$ÔTĒęō>ņęÎÂtKiĶNņRXįĘ!UUí{ĪÃÂ1čB1Rŋ6—Ŋ ŨŪĶ ˙^ڈœxŸwõš0S¨eĒ>ÖŲ6ķAAøráĨaąGzĘgĘß­Û÷nã>č1ŠS_ĄP§ŪA=;ęöņĖX‡  ʡŪuö‹ą™vv˚¯*hķž<ŋēáĶË|ÛAÔQ~rhgY.•ešĩ LĩÎĻj7yˆ8YÛUĨš7NŖø{ÕX3cHáŅ'Įx÷Eë>Ī7éåöՕ{Ũ°Ŧŗ÷ ›Q¯ßēíuûBžäËCzÎûÖ:RĶ˙´ˆos#āf(^!7û ˙°ĮfԛzŨū4Ŋ}uûĪdR#\wŌJĶ×oĶÛ7iķ!ú3zBžüķ 5ŖŸ{ņƆ ž_8‘¤I/MßÔ›ŪžIŸŅŋá#ĀŖīü¤pįI+M_ŋMoߤ͇čĪčų¤fôŗ.ŪŒ ĻoęMoߘ†-^´Ļ¯ßώ¯ƒ Ƅ@õÛJOøv?ŨÎŖĀ=_ĐíœÄ8ÆFLŠŲf{™)3ũ~ĶņÆÜ­#ƁOĶÛ7lLF1§˙õ¯ĨsD’O%;Ģúę,ķzŨžįL’"ĪWĘtímB'Š M_ŋMoß æB/ēŅ ŪE^ųévžpīh[Î Úģ“Á\õÛJRãÆ×û–o÷Ŗœ‹ģņŦ|ån/ģ™Ë!`ˇ ˛¸ĘÔU›&{ųV ™ŧŖÜĪ~ÛíĻ!×Ew^õ:“vMõŨAáā6(8TŨj4Ŧļ÷SožũÅ-[.xČDĨ—ēÜŽåÖ¨rîĸ^ž7Õ3u„ÂLßÕĪ÷›Ūž~úTõōz­ģ&Üü‡”tŪÖkÛäX‘ŖÉ­cŽXīĨ8đėšztģ!P=no”„؍h3mįTíęEŪš~ŪMn.˙EhiįžãŠf7éÍĻŌôõÛôöõ3WĖms͍zûēųNTNûPUoN*íÆWWÆ÷Sōí{.Lrü$—ō͓ŨúIļēiŌ9]i%†]Ęú[/2qØívũuÖīD‘šrĸ¸ē¤Æ•Éŧ(nVdéĖ2>eŧÜĪ~Ûí j׆Ŗ(ƒÂĄM¤ÆmZŽwFjrĪ^°RĶ Jí|Ļŧ^ëŽ ×(#9T]$e ’hŗ×d°:ē^Ūųäåj×Ėm˜~°ŠŖõS˙ ŋcîš'öU‰ŊĨđŖ+˛NŸ1ĪÍ5kGâ\ _7Ûlŗ¤K!֗\rIz–\‰ę$5öOzINžkM[wä…õkN‹b!ĪŦU†kÔŪOļh§ß­e: =įČ#L{wš¨ß^lŪĶE´ėč&#N8ᄤˇdycĪ&ŖÔO 3i;y#"‚ėĸČīĨ=ôF8Yģd3ų(ų““/wûŧÜnũĨĘŨWĨ“IßA ßč&ú]%sČ`īƒ!ŒŽ›uC9ųxÚ´×Õîä#ŲŨ‹ū&œØžâyšˇČx×ēĢΏԕēéՃžˇÃǝÎú Š‘ÄŽâgCļØLžË.ģ, ?•1QmN)k€Å#Šį;eRcķ˛ą } dzް %ĸŗŲ!+žÍË"#$ķ™…ëŊ,ë”j ¨œŗ[ģŗÔ§˜øžoã%\,“Wũ&™d‘&BCą‘Ä’âhØČYp…ĻčŖŋ úĄŋÚ\î'ÅDŊ6GB¸įž{ŠÕW_=%ėäĩAįėžZ|H!Dé¨ÂD›sə×a ÆÂĩ8)ę§ 8„o&5ˆ¨ŒíŊā€\ÂD]ڃĖĀTŊ|ī#ČĩÁB%Ė8bâ—-% $˜ÁËR]cŋWĩģėFVÁã"Œ‡•K’?ķÂø°œs L÷Ũwߤ•I Z…3l-hú Sˇ$Š–ˆ^Ŋæ†Jš ÛøûŋyĘđa][¯dT/kĸŠ1—­]>cĀųįŸŸÖ…ųįŨŠ›ŧc|0¯()9ü Š!/ÜūGŲ&lŦ”‚|‘…r˙ũ÷/Ž;î¸dø ģVYe•T79EAQųˇâŠ+&‚/üŦLjŦQ}Î @É6á2æ|6@3dœÍŪûČ Ę ųäßr(Wĩ`°ŠÚ+(.…wÂŸėŠ’)š kąÅK}ĸÆ™ą^N<ņÄJYãÎ}ĮŪb ākßą7!>úŒÔj™kü)JX™Lov¯æ:JĮđZŅŊæĻˇ¯ŗåæ‰õÁ0#ÖˇuÆĀĀlŪæš†ˆ3tØë˝žl^™ÃÖ}.eRc įč†5{”„äęōķŌ—ž4#…ÂlŸĨģđų×^h]3’d R’‹ũ:{:ŦQßĪ^gĪVɤL.'í°Į{ą"ËŦíą^%Ā•û fúKo ĢėÉ";üÍ;ĩI´‡ĶäĶÆĒĪËy´ 3íĻ“‘ķä}ŒîW%srČšBNKrĮú÷72\âæ;î¸#Ճ!ĨĶéotGōŠNIGŅ'‰‡ÉJz ˇJVuÔQãXŠ}ŊŗÎú Šąšb <6Q“ ãG €oą)˜´„ŋ ÁČąäŦu6ģÎ35ág6H›ŋÖ‚˛°(Î5! °qĉ,”Iâ˙åŌ­Ũ ÂÕIjx :CLœca)°@)9K‚EŖXú%9§d‚p’‘ZĘũĖ›ŧ“ŠuPŅį,Ŧ(žÅ$,?e÷e7LĘVRm˛é#€ŠIf<X^˜‚¤^äCąHĩÂ2ž'‘*é_ũ‘ä„%ĸfÁjûK^ō’„AĘ"D  YpNzšĮ ŽŦÄ,˄œN` 2yŒmŨڝëa­!xŧ_ņ]PÕ¯æE‰dié$5Č_ÕÜŖXQääÜī 5}ÉžąÉ!ģĖ]sÎŧąRĖ)6į|voē5Ņ)߲"NnZk6OJ3yÉ8D‰Ļ˜đ›‡Ö/Yižw#5”2’1‡âސ—“üōúŦģîēI>Ģ×ĻKqĸ,P^īgé‡Ô° zrDŪe #9Š6ė˛Ąĸ“ÔtîŲč@yĄXTÉJŽ÷Ēۚ¤<ØwČ JŲ ×˛ˆ‡ĨJ–ˇûE͘[ËšíŒ.ۃh‘[œâģ”ījsŠŖtŒŖŸMo_&Öģũ~‚3ĐŲãČs,ŸŠA:ĘágֈšĒ ÜžSž_eRCfЅčdŊ qā•°į"”JžuAgéFj˛nÀC†tžũĶ^^_k€˛mîĶ…ē>.kƒG‰‘‚AōnrČzĨˇĐģŧ×ģ´Ņų?ũ͑2ŒåŒŦ~ô‰.áŨp/ōSõy9JŖ“ÔTéd Õävv“9öųŋūõ¯s"zāÁ`OO$§ÉV¤2Z†aĪO§ŋe™+2-ë(ČŖ rXĨWwî/ãX—ŊžŗÎú Šą(-&›-ĢĢļΤ&Ÿ;ąŠņrXLŦܖŠÍ×īĶ‘ž\&ŪA˜šXdī2ŠŠ:Ņ­Ũ¤†P \T‘šÜˇL؄đØôrĄųŧ|.(gúîFjXkˇ|ҁ…! Î Ī˜v:$ÜIjēaRŽģīŒÕ5ɲI ag!éO.ŦŦ ŨÎd[¤-ãž5›?<ķ­MČ „sQ6(æ Ėlš°4š”6J—įrh^ųœ‹ļníÎui'˸:u+ ,k“ž´ŨH !Z5÷2ķœâ‹8 =D”ÂSĶĢČkÎsdCRcSĻķ8# æJųPįtkĸŠáÕŖČ0ö[6?ķŲüR?âģūå%"‹ē‘ŸkŖMI™ŠÔĨ<įŠ5o-P‚Ļ"5™,ecĸ¯ŲS“IKöĒęW6Šx9ÍĒ™K'ŠéÜ+ʤÆģĒd rÉōĖ3JögR“ë˛ß „äw&59lĨS–t#5 (ä'—ÂČAūæŊ&ŒĄunŒÚ\ę(ãčgĶÛW… B!÷%ڞˆÛĮŋėįŨHMųĸJļõVž_eR# ÂúĘFë¯ŋ>íQyžæ)ëRŠ}ĒŠQ¯5§]t˜NR#dėcûXRܑ†ką ĶuĘ ûŠ5Čø@ß°ī’›”t}"S}æo9üFú•‹PSû>vÉ,E„Šęs˛.—NR“õʲN–I1ę&s3āÃëĻ”õ;ãĄŅ–2Š!›§Ķ߲L„ lõQĄC0øfRS%+Įąûygõ;RÃë"4ĘÆ†eŗB”=5āۘYü :€Íše˛s͎€1uƒ\ž0eRCyļ X&lāŧ &ПéHMˇv$Š ëē‹rËR˜I +v—o?ËĘ< ,ŌĪĘUĖëûŨHMšŸyBS6(Qp$\(ūŧa”zá!¯gY2Ša!õĶ °ŧ°Ë²ŠÔĀŌ&íbû)'ŲJ¤Sá`,ĩ™ į>gĄ¨+[4'įÕ0G%cÂ=‹ŦąŠ Ëņe͆ŋEŠ'{j2"Üēĩ;×Eøš™ą|ŲË^–Bžãssˆ^7RCpu›{ŪÃZC!9áŠéGô˙;Č7ībŗĨÔķĘÚhlx¤fē5QîQ9Ŧ‹RŽđ ]@ôÍE Ĩ[¨.…A8‚5+|c¤F(ˆxqĨ~16˜ëŦ‰~¯ōÔh—5ĘkJˆu˜IõCRŦ)k&ú$tÎz/ø¯Cj(gŨd ,ë^ę*Y‚Ôtî;Ŧčââ…wĀė1^°€QYŽŠ͚­ŖĻEĶŋÅZ°&˛‘‚bn-Ķ1,ˤ†"͸!:ĸŠ1íÍžošģęp oŦĩĘģaOÎë´Rã;ä‡0yŪ {5#ĄuNŠ’ĸtŅ ö|!ģū¯mä09†o$Ÿ[īt!úųA—ŌgnéœČ›ĪԅH!LUŸÛĶû!5äS7™“ĪŲöJjč-ŧ>Ķéo2‘gŽŠÆų+ëžŲYžš‡X€ÎŠWP„w°ōZš&*˛äûå~RÜĩ›Ĩ€ŌDŠâD@ąĻúÜĸ§čP–‘%„ĘYVC–D}ŽÂ¤,vs;ŗ “ÔÛ°hY]9aDA1fŊāĀĘĀÅ*dl|—B#Ä–>‡!Ë<Ī;C šÆąlÕĨ`ō~ølÅĘÂĨÛĘ×,ķŽtkw^0áÚÁ‚GĘBkÃ1^æKš08Š|Ĩ3KRÎæ ËĖ(ÄÚ įōyŽéˇĀПhúĻŪôöÕÁŸō̘kւsmųLI–3ÖJ/kĸüŪōUÉB1ŦwëDAÖmĐæœšCYAĀ)$æģĩÄŗÉ a-›Ÿŧ¸yŲŽŠ<5’‚bM[ßŦž¸’#_n'ŲËrĒÚÂĸĘûBŽ1”IuĀkJAQāč9a7šdü„Ž”7ęō^a’sHM7™bũ3 QŽ:=5dosöԐˇdx•,aŲ&ĘûŐq ^/ÄĪX1ŧčyŸå(Üxj"üŦÎ Ģ˙låKöŠâ°oØKė/ųōĸLjœŸ`l°žč3åų…pû{y~u^élķ\.ŗŸw7ų;M_ŋMoßLĮ–aŽĮ´ßë•$ĖUûdŪÛDt P ‹t ŪF:N>¯ĶO›­ kˆ´ŦŖM%#Ŧ-2Ąso´gĒ ™ëLxésûqųsōĸsöōy?ũËߤĖéUËī6FŪ¯^äíLú9ĒīÖYŋc#5Ŗ#Ūqåęl™”á˜9n\ŋy…rD †…@ĨcXm˜ĒŪώo˜L÷NĘ4īŖ].Ųķ1ŨwãīĀ ¨ŗ~ƒÔ ų¨+h(u„Â8ēĐôö“~ßÉ")LÅDÖ:áM“bąë“øŪphúúmzû†;:ũ×Îû#”KXĒđQĄŪQQ#PgũöLjÄüģŲ'J ´Ļ¯ßώ¯}#-F‡@Ķ×oĶÛ7ē‘Š7íC Îúí™Ô°öåíƒ$ZĖ^fvËR><ŲD$Bž4qTĸMĀô„|™Ŗx"úC Ž|é™Ô¸eĘmYå[2úkb|+F‰€ƒânßsÕgSKČ—ĻŽL´+˜/1C@`Xԕ/=“WæšAĮõ~Q@ =¸ԁOWh6ĩ„|ięČDģŠų3$†…@]ųŌ3ŠŅ`WSē Ŗœ7`X‰z@`æ8čé:NWy7Ŋ„|iúEûšų3"†…@?ōĨŠ‘8Lr œ8lX‰z@`0Č1 ą¨ÄŖM/!_š>BŅž@`nBžÄŒa!Џ|ŠEj4|¯ŊöJĪŨW%š‹2#1ŖėĪm)!_Ú2RŅÎŲŽ@ȗŲ>ĸ˙ĀđčWžÔ&5ē°įž{ˇÜrKqÎ9įķÎ;īđz5@m¸l×[oŊb…VhĄÉ ųR{Č㠁ĀČų22¨ãEĀŦC`ĻōĨ/Rå .¸ Xũõ‹mˇŨļXzéĨ‹å–[.%y‹ŖGĀĩ‡Žlžų曋“N:Š8÷Üs[rÖ Š/ŖŸCņÆ@ !_bnĀ°¤|é›ÔäÎüņI™ōŗā‚ —^zé°úõ@ĢŽējqß}÷%ÃÂōË/_lŋũöƒSȗ‰ĘčHKųŌԁ‹f-@`ĐōeƤĻ˜E{@ā!yHņŸ˙ü§‡'ã‘@ zGā˙øGąųæ›'Ÿ|rņđ‡?ŧ÷/Ɠ@ Lƒ@ȗ˜"e‚ÔÄ|HЉ‰Ã@āđÃ/:č b˙ũ÷/öŲgŸaŧ"ę YŠ@ȗY:đ]ē¤&æCB€Ōq@  —É<đĀ)ŋ™Ŗ@ 0(Bž Éɨ'HÍdŒcô"Æ!ŠzđÁ'2#šę~ûíŪšÆR4(h'!_Ú9nÃlušaĸu@ 0‹ČVÔ AxkfņdˆŽF äˀ€ę‚ÔLĀ ĸ ~6ŖŽ@ Č”­¨ųŗđÖÄüA ōe(N^Aj&oLûęQ\ĐlņĨ@ č‚ķ°‡=Ŧ˜ūų‹ģîēĢXxᅋ?˙ųĪ…ÛŠūū÷ŋn@ ô@ȗžĄ›č/Š™čáíŊsAjzĮ*ž Š¸čĸ‹Š 6Ø 8î¸ãŠM7ŨtÎE$§vZąĶN;gžyfąÆkŒ@ ÔF äKmČfÍ‚Ô˚ĄžēŖ~!@ @ ­<äėŗĪūO$DkëđEģÁ"ĀcˇöÚkļŌ¨-@ !#đķÎ;ī?0d”Ŗú@ %|ũë_/ÖYg–´6šŲÂܖ‘ŠvíC äKûÆlX-R3,d^īÅ_\l˛É&ÅŅGūu“ČŪ{ī]œ~úéÅŽģîšū]}õÕŪ‹hŪ R3hDŖ>ęŊ˜@ 0,Bž ŲöÕ¤Ļ}c6°?ūņ/úЇzÔŖŠ{īŊˇxžPÜ˙ũÅ?˙ųĪâ÷ŋ˙ũĀŪĩ 5íĢ6ĩ4”Ž6V´5h!_Ú5^ÃlmšaĸÛđē:ę¨â°Ã›ëzUÉņxlvß}÷†ˇ>š7 ‚Ô Õ¨3ÂCbĀ°ų2,dÛWošöŲ@[Ė;ķˇŋũmNî~˙Ũī~7ĐwDeíA HM{Æ*Z@ ˙C HÍ,Ÿ eoMxifųd(Š"HMˁ@ @ h#AjÚ8jnsöք—fĀĀļ°ē 5-´49ÂCZ0HŅÄ@ Ĩ„|iéĀ ĄŲAj†jÛĒä­9ôĐC‹}öŲ'ÎŌ´mđÜŪ 54ĒKÄAۘ@ 0,Bž ŲöÕ¤Ļ}c6đ˙ã˙(vØa‡â#ųH!-ĘėE HÍėûaö<”Žaĸuŗ/ŗ{üËŊŠ9âˆ#Šī|į;é­o}ë[ŠūũöÛ/ũ(‡rHú‰Ī‡˜ÃYũčG‹į<į9ÉãļâŠ+ö-á‚Ôô ]|q "<$ĻG  /ÃBļ}õöMj.ēčĸâĩ¯}mņ°‡=Ŧøņ\<éIO*X`ö!-&y…nžųæ´WXa…”k¨Ÿ¤ĻÔâ;Āä đŸ˙ü§¸ũöۋg>ķ™syî˙ú×ŋ&ų2 Ū|ųØ9ÚĸĀä ĐŠ9ōČ#‹e–YĻXoŊõ&‰čI 0A 5[oŊuqüņĮ×ęUšZpÅÁ@cxāŌÉ /ŧ°XiĨ•Š-ˇÜ˛xÅ+^1e{?üđâ„N(~ųË_&#åú믟’Õ%YķÎ;ī\|čChŸ÷Úk¯âŊī}oqĪ=÷¤ĐŖ(ôŠ nģíļQŧ.Ū#B 6ŠŲx㍓`|÷ģß=ĸ&Æk@ š?˙ųĪÅ;ŪņŽžŋ¤Ļg¨âÁDxH °ôčÁ\pĀ)õg?ûYq×]wú͟ŠųįŸŋō üį>÷šÅkŦQŧõ­o-Î:ëŦâ‹_übĄžŨvÛ-ũû˛—ŊŦxÃŪ0 ūˇš÷ŧį=CéŨwß],´ĐB­ģ[eūđ‡“7{—]vÉûâ%ÃE äËpņmSíĩHÍŋ˙ũībŪyįM.č(@ Đ|XYY?{UD‚Ô4LÛØÂ8Č;úQc|ŧ÷Ū{SX*rōÆ7žąøĖg>Sl´ŅF•q.oûíˇ/>ųÉO&¯Ž$ĖÎč-ĩÔRśŪôĻbõÕW/Ūüæ7'‚sŪyįĨÛ2˙øĮ?[ląEō}đƒ,ūõ¯Ĩŋ¯ļÚjÅWŋúÕâŅ~tą÷Ū{k¯ŊvņķŸ˙õÃ;øÃ&oõ駟žÎsĖ1ÅrË-—H›3Ā˙øĮSŋ^øÂĻßy‹ÖZk­bÍ5×LõųĖŊ‡×kĶM7-ĒÚuõÕW§÷ †Ú‹§<å)ŖôYúƐ/ŗtā+ē]‹ÔLJõŠtö‡wÅĨņ‰O¤0yŠášqŲ€ŗ]wŨ59á1™ŽÔđœ įœsNō~\rÉ%ŠdÎTágˆrÂŖÃ¸ŠÔøūQG•Î÷"W˙÷˙—[ũõS~€AÉÎxsqXS’ļō5œŊžOb9‡4…G ǏŽķ=īyOʄŨÖÂ=/Ū{ē¤f:„âī@ @ ĐDz&5ŽSüđ‡?LīŅ÷ėSđ)ūtÅœ’ˆMÕSŌš IČvØa‡t?%_›e`ví'Ļ˙ûīŋē˙Ÿ—)‘dÍīŧ¤÷øŽv#=žAô´G"4YĄŊë?øA"8ŧAˆW7ŧRu]Ķ6m|ä#Y\ũõ)ÁėāækU{nŧņÆDœJãëvßøÆ7R?ä_Đ>šüĢ]ĪyÎsÆŗJđÖ¸(` F}#JGßĐŁ@`BžÄÉôLj&áö3‡÷‹¸ų曁ˆ,'öŌ? ˛0/ Ŋœ;Ą°ŗĐįģ÷ˇÛnģäÕAАΝ¸âŠsÍ(Š5eŲŗ}ėcĶ՗žãáØKVcž á|˛#{…Ÿ÷§ˆ§@b4m•ų@Œ°ûîģ¯kģxC"D@â0ĘŊP,„MŌ2äHĨŪų‰ŠúĄSˆ›6hĢÄjŧHĘĘ+¯œúœ31Sü8äÁüŒ9@.˛ĮjÕUWMIɐ5}•ĄY}0pU(2Æģ3ũŠÂËį÷š]ۃŧWĪ D`yΐJÉä$ŸC܌•Dqplķí>AjB菁'úņî@`˛ų2Ųã[§w=“šI¸ũŒb˙ÉO~2…qIЅÄÕ–3ŗđīŊ÷ŪI9æ <— īå›÷ƒ×ŌĪKS>ķ BAæ1Q(ʈoež'€ĮČŋ”$FؙĐ1E›xzöÚk¯¤ÔkŸ0-—tk×ē뮛¸Ôå\‰P4?GqDąÜrË%/ĨŲņnE™Ēš(ū<;ÚŖxŋ3ž&mB Žžúęä‰áɁ#ÃK”Ë"‹,RhŸždĪÂÃ3"ÜKũ˛?įâ°ŋzxtĒđ‚÷Ķžö´RĻ €BüŲŦõÉ8Áˇ[{|O?atAėœúÚמ–>G&?˙ųĪĪå‰ĢŗĀšōlÜ~֔‘ˆv@ Ā0˜5¤Æ™įS(āB’ŪôĻ7%o00Ęēđ2DÂ!ugExüØe—]’’íüĢ€Dc>%ė‹į‚g†wiBx (ōÎŌPžßõŽw̝žzĒK¸Ĩ›BÎ+°ĶN;%¯"ÁáyÄ@ČE{Ųe—M!P7ŨtSeģôIXö^Pæ‘!ĄWĪ{ŪķŌwyž‘;īŧ3yŠĻë‡ö 5ËäĖ|ÃŪP,ĩÔRéģˆ rˆ0Á ŲĐž§ũģŪzë%båėâāėÂā9Ūž,$â%/yI"s§žzj ŗƒĨ°Ā*ŧF¸ņŽđ´9ëä<R‡Ŧų\?ųÉOÖUíɤŨ+BË\V=xæ‰Đ8írŽ ļm.ãē(€´Ÿ,ŪmÆ:ځ@ ŖE€žĶ3Š™„ÛĪx„†Q¤…AQ~<đ¤p_wŨu‰đP`]@ GnaW,ûÂŌ„5)žį  Sō¯šæšäMá áŲNFĄFLĘŊž‹ü~žä€w‚Rī°<…üu¯{]ǃ7„R(¤ aŽUÕ.×;ī“očRŸ6ņ !BÍx3[ląNåvļŠúá`š~“ËáwúŒč Ž‚FxÄMˆ yt´OØŦäPģx x—83Ã;’‹::ī~á _Ø/¤ úŖmŧoÎ+ņĒ!’ˆĄKx…Ô_Õ&dôo|c é3'|O›H˙‹L’yûÜh—ųß6.RÛižE™Ũ<āQ@ 4!_h;ëŖįöLj&åö3 9‹žđ'JŦ̧:+á*_Đ˜\(ŧ,úĪ~öŗĶmbŨ īåēü /Écķ˜DTxāĒ~ŪŠšļõRĒÚÕí{Âå u—­æŊöŖ—öt>ãl ō†˜å{gzĘ$iēēģáåúfįaōiĶÕS՞nßQˇKx暚ôĄĨžËA¤Íōío„^šŧūõ¯Oų|^ūō—§yņĖg>3Í/ØJŽĒ Ūåī[mĩUúî駟žæĄ|;ßúÖˇRUĮsLzvœ%. 'úņîP:bĀ°ų2,dÛUo-R3ÎÛĪ(dBĢ Ą=”MĄE_øÂ’2NÁ¤`Sv% D|$ޤ´fR#ÉĻĪ(ɔÍ{îš'eĢG4•3Î8#“%–Xĸ8ᄥPˇw\pÁIF^ʅ‹Pœzꩉ PÜ÷Úk¯bå•W.vß}÷‚Eh‚#,‰bM)ö™|-Ȕ÷ S’tƒ 6Hųo`í}ŪOą—Ôį<.ČR°!iûîģoRļ)×ú|ŽÃYšvØ!‘d ‘‘WR÷÷ŋ˙ũÔ/dD^땝|å¯L™Ô_ƒåqĮ—ˆœ$åz{ũ9ûėŗڎÔh÷+^ņŠâČ#L„Ã;Ģęp\¨–ļ(ë­ˇ^j̤™”ōüČë“Kn—g$ķCØā˜Cõ_>œ›nēiŠAŧcĸ͈<@sJ{%čDF$=Í {Üã—Ūƒ°ĀŌ÷<ˋ$ÜĖûQá}Ȱ1æ1“HbÕ3Ī<3…ŅŨxãc•m&5Ö~į;ßI‰P3QDôũ(‡rHúQâķfâĀc=ÄxĩsŪ’sö^ĒW\qŦ˛lĐ/ųŌ~šōĨ™rŋ×ũxPōĨŠįíggâĒĢŽJōŒåōJą—@’ĸĒP~)ŨŸOúĶÉ3ĄPœ)™ŧŧ ŦķĘmˇŨ–>—AR×Ŋ÷Ū›bz˛Á‚Ī€uļ×Ļŋūõ¯Éˀ”Pt Ž‚4P˜ũĢ]wß}wRĸ)ÛŧS‘^J4Ĩ^ą‘ŧwŨuW" Č 'ÜɁônág”rD†—A’QÄĪŋ”C ?y((ņˆBU.eRŖČĶk_ûÚô<DČįūīŌ†[nš%%…3Ķ*ĢŦ’”z…‡§ĒãQEjÍĒđŗÜ.s@rO혊Ôđ!Ä<@žGr)o|`€4žæ5¯yЙ*$¸›gČ+OÖu×]—ÚÅ[f,ˇķ4ágČ+O™9&<Íxŗ´íöŗ‹.ē(Í7ĄÆĘ_`Æ aŧ;˜ĩģ7ß|sZd39ÚæōĨÍŖmŸ4%_ZCj ĘŖ…÷Ĩ|QŇËËrÅWoŧņœągeįE `ō4(c–x–uJ,%™'DATxä!“Ē\)Rƒ°ėŗĪ>ÉZī}ā\æwŪ¤Ø#˙ū÷ŋŠÆ3ĐIjY‡âyŠsŲ"&ÄÉ÷œ¯ˆ°:J÷t¤qáĘįc7MØ/’đ(õw#5‹.ēhę_nĪjĢ­–<Âĩxˆ„ZéP@„Šy˙ûߟ<#0ķ=mŦĒŖ“Ô ?HĮt¤&÷ĨLjLäģėŠAǐI^.„.—6Ú(;ž8xšcÆzÍ5לķ ĸK‘6__!mĪxÆ3ŌüBVĖ‹n¤&_ öã&5mē(qˇxíĸ@ķ@jxŦ?ūøæ5nš…|iŨEƒgũʗZ¤fœˇŸQžYŲYl)ØÂŦ(ķHHŠĄ(;ãāÜ %›Į†WC”í /ŧ°¸īžûR8E\Ø…Ûų ûwܑĐ#ņˆt†%ŋŸ‡'ĸŠÔ waWB§„[ąėS€)hBˇīW'%×{ĩ™á5 T#5ŧˇß~{R˜ŊSX%y %ŦëœsÎIŠ:EÛ{…v!Bå’=5BÎôÕyĸõ×_ŋøä'?™BAā#ül:RŖŊ”b›Ī“ūđúđF {ڋčÛYg5įL 8ōnđUÕ#sË3B‘O˜!5Č@įíkĀ‰Ēįė3Kág,ūÎņX!ģBÅxZ„ ^sÍ5‰Ø"iˆ^™”Â郭ŗH,•ž‹ĖzŽLjÔÍãëōígAjÎOg¤z- æ7ĪZ”@ h. Ã#T[JȗļŒT´sļ#Џ|ŠEjÆ}ûk9ĨZXk:Ë:’W@hBšguwđŨÁ~ 5˛á< ŔÎkīĸ”;GQžŌ™rJ)¨/Š.Ś2Ë3T.”[ÖDCApŪĨđžh‹ƒåŲûÃ{ƒ 5qŪ}B.X˙‘$!fH‡3ßęå܊į)ûH…Īœåæäžë˛‚\ËŋžKá÷.uƒ˛īŊIš”¯tF°r(œī"+BÉ´Fĸ÷¤$_éėŦ$ŧ‹ÕŊĒ˙‘T˜hĢđq˜žW›ĪQh_įUĶŧiŧa Đ%—\ō RƒČĀĀųíįõsÕu>… cD‘W­\ĖsĪ{‹ų&R_ĘW:#‰Č3bjŧxԐÖϐš6Ü~ƛi}Ôšmoļ ūč 0NœˇŪkjz ųŌôŠös#PWž´ŠÔč*E1é5ļŪķ”QéËÅ9 JøtĘEW¸Eˇ|+ÖTOXĸTŽ_]jӄXē”ųįŸ˙AÕRĸՁˆä‚P΅ŅÍ3Ī<écué+ÃTÅģä‘ņ~ĄWuJî—Ãņå0.gix¸„gMWēÕĄũ°AJËÅ&$¤Ŧ ›ōsę…KgžžÎö Ú_ŽÆH(LfZ;u71l. 0×yGŖ@{Č ž›Ūâ/MĄh_ đ`ęȗZ¤fœˇŸMŌ@ģm­Lj&ŠoŅ—æ"ĐtRãJsįĄ„îE ö Ā(%"ĸÉ%äK“G'ÚtG Ž|ŠEjÆyûŲ$ ¸,×ĶæPŦIę[ôĨš4ũö3ˇĪšå/‡6ÉhY ”Ž-ėV˜qSKČ—ĻŽL´+˜:ō%HMĖĻ@`– ĐôÛΜ=˗6Ė’!‰n€Đ_į5Ũ„ŲÔōĨŠ#í ĻF Ž|ŠEjÆyûY úøpČŲ|~g|-ī›ÛŽCĶIÛņ\X%ڇĢËgšZBž4udĸ]Āôô*_j‘šqß~6}ˇ{ÂÁv7ˆšŪØ dƒ,ŽvÕr.‹-ļXē=Ë-kÃ(ČĻŖÜ„–Ë 'œŽE.ßÖīģ]qmC~čæ57žÕ-nVÛmˇŨ IEQ\įí˛WHëģß]0Ė’qh´Ĩ鎟M’|iãüˆ63A Wų2“wĖäģ!_f‚^|7/ŊʗYKj$ sō͟ūô%ԜéĐ­žúęÅë_˙útŊ¯ÃŲûØĮŠÃ;,]{ĖZ>č"˙ ŌąéĻ›ÎŠÚĩÔnLsöL ī„Í _ƒ|ĶM7ŸøÄ'jWyã7ĻäŖAä!Õw×{#ŠÃ,eĘWfķƒŽģé„Ō1čúŅ!ĐĢ|]‹æ~Sȗq!ī fŽ@¯ōĨŠ™”ÛĪd¸_{íĩ ÷_ķ<ˆ×Sx7¸ĪĪ?˙üâ§?ũiĘKsėąĮĻ›Ęx_ä$A‚$Ē\iĨ•Ōa™K/Ŋ´Ø|ķÍq‘͆đ”_F˛OEۜ 6Ø 9]XúO?ũô”ČQ. ÔuĖr›H8(×Mg;$ŋ”ģEÛ]Åėėgåžq ˛/rēäâ:i„Æ{Õ'‰§Û_˛gĘUÉÚ'Oļ¸ ŲŋŧWŧ;ōîđ~hĢöË#¯ oĖx…>ūņ§Ü.ŧPÛlŗMJXĘĩÔRKĨ<1ÚĻh›g$įŦÂė1yL×/A&\ŋũío§„ŒrÁä+ĨÕås9cxĨ<ËôŊī}/‡wú›¤™GuTz? %ųtYƒƒŖōŌHn _ISåšAÚ`Ú ‡™/ĪņÔPG(ŦŗÎ:k¤õÔKōÍI‘/.* Z„@¯ōe\] ų2.äãŊĀĖčUžÔ"5“pû™œ*=Jl)$ŠŸC—ä~yęSŸšvJʉ4lģíļśßüæ”įÆīG}tR¤/ģė˛ô] Ōã )‘)Ųđr}3€° RęŲjĢ­ !cȔLĖ<9ÚĨD Ųčl‡äšrķ ’["‹¤ĸ”} EsN„%'âD.$(E0„@ŽdDžć‡gŖ6Jaņš6ųWB5ī:å”SR›Î;īŧDŦô[ŋ ßųŨī~W|ųË_NDPH˜CŖ ‚T!ę¨ÂŦÛmW÷ÜsOōĜyæ™)AčZk­Uđ°Ŋüå/Očƒ1Aĸŧ÷ųĪ~:“ņĸŊ(aė_$Å÷áąÂ +$B#W$ĄHŒÄšŽ~ī{ß[HĀŠ>ø¯n8Ė|yާ†Ļß~6 ōe<#;Ūˇ’7~ĻËų5ŪV6ãípú͟ū4Wž¯AĩlÜgūz•/ƒęoŨzBžÔEŦ™Ī{ž7•ęVM’lîUžĖ:RC™Ũyį‹“O>9%\¤Ė#)+¯ŧršČ¯‚˛īžû&Ĩ :î¸ãŌĻ-Š&‚Ā ŧËĩĖn¸aR¸%ž¤¸īžûîsŽk^tŅEYyÉK^’<=Ȓ.ah”gWØæ3Âք!íĐ6ŪīF†Ž[nšĨøđ‡?œH˜ļæĸÍÚ+ÜM/ēčĸDdx,ôß÷ŋôĨ/%ĪT>sà ƒ,8/ÃÄ{¤č´ŪzëĨäšÂŧ„‘!c™´lˇŨvÉ3ƒĖčWįaoD AŽĒ0ë&$ÜMÎãÅŖ…ĖŠ˙ /Lí?ņÄ“ !Aš /yu4cĩîēëøĀRxL.šä’”Lö~w[š`N(O Oe—2 l+J/e@T,b„įÚk¯MáW~¯ēÅI8”÷|đÁIąæY <,Šē œ=ĄøwļÃģ‘„‡wEũBŲ*›a.6Dd€7!ņíũõ¯=‡í˛Ë.Åã˙øŽĨđœuÖYé{$ĸâsõŅBĀ„wņrpĀs­ÄåÅ/~qj›v• o Œ‘ļ*Ėē-Ju!,Î&!‡ˆ×ŨwߝBÉā˜ûĀō‰p!99ä~Úô´§=-)Â<đĀäŅņķ‹_ü"…™!E¯|å+“w-}¨Â!×?Sa3Žī× ã?k’|9í´Ķ –!ÂŪüšūų6dŖ>sĮBžͅqƒA„Ėšiq~•Wēß3w3mCīķÖö{Ą ´Ô- Döķŧ[Éõ‹¨ĶÆ&œųëUžÔÅmPĪ7Ižtöi’ÎøSŪ ęŒī æT/õĖDŪôR˙TĪdŲÜí‚&„‹‹ÎĶôŌĢ|ŠEjÚ~ĐÎbŗY ËEx•đ%¤€âOy †H&ÆĒ–Éû—ĮiļDå3'žŖ°/ŗĖ2ÉSS>´ŸßÅōëo66^Š´MŒšpÎÃyĒv¨ÁâYŠ% Lņ]ʹɛ‹ Ė3Dyĸœ ãb]¤k/ ‚\ąŽŪyįi“Fꄧi’ĮŠŊ>į%ĄЃ—†įÄΈ…đ;^Œßģ„āņ~ 5cm|ėc›Â͐Œ*ĖĒS^Ļ02álŸ÷"›ˆ EĶû}Ąoŧq 7CēœåáĄBúxĄxĨ`GČøÜš#¤ŽČ(—ŗ8Ą>Uá€ĩĩÔš=d¤Ļ)ōÅ<7wÉ kČüâÕř;WGgÁĖ?ƏÎ3w<ވūWŋúÕä&k(ˈŠg­Q!–Öļ0RŪgŪüäbž[ŋˆMÕš5ëŠÛ™;^Y˛ôģßũn’;Îâ1üX“Œ ÚÆLž : /dÉO~ō“Ę3wÖē3Šdã„3ŠÎ’kū%kÕOF[īž¯sϝsŊōōNuĪÅģˈB“kžĮ3o^øūÅ_œūNŽ7˛˛ÛE0dŸī/šä’)|—1­ęœcŽ_(onc– gM;ķ×Ģ|—ülŠ|éė˙8ĪøļAŪLwÆˇMō†Lë”į"BĒä #TˇĪéSUįŧģÉfÆéĒB–‰F0ēÉûq­×Î÷ö*_f ŠĄāō¸ ‡MÍæl"8¯‚đcBNĘ/Eų¸˙ūûSXĨŪ&͒ë†â, ŇbOĄH—KVŌÛąi ­âEá đnĘ6oNU;(ö§§Ŧ#^ęúÕ¯~•”\œáAZvŲeSŸ‘ ä‚ĸf‘ Ū™]ŽÚ`RŗÚōÆ XūŽ?úėsJ…?ŸkÉų„īņÂP´œûQwviֈü`Z…ŲTžJŲõ×_ŸÚoŗ§¨!ƒãgœ‘%JŌEŠâå2fúī< ĨÃgÚĢ>Jł˛§P¤`Ēøž‹xnēáДŪO;괛ͤÆâĒwĘŊųÅS;ė3wæĄÍ‡ÁüS(ņgîVČ'm˛!!'Â^‘}^TžHáČĪ‚ĩP–B/É,Ū€ĒskŒ"Ugî\ B™ <ˇ đĘgî(d›õIÎ1HØĖ]†Ru掁ŠÉˇC"CžU?BÄØ ~ΐ/Bpëœéë\+äŅTįđD`‚Ŧ‘=úÂ…´Ā„,GÉEJyl,Ŧ¯nįŠ@—e yÆeĒsŽåŗ‚dQ–öĻųëUHiš IDATžô#ģņ&’šqžņmƒŧéåŒo[ä ƒwÕjēM•ŧqΡęsd§ęœ7]Š›lîļ~6Ûlŗ´70vWÉûōą†AŦÁ™ÔŅĢ|ŠEjfķí!6.ĘFųJbdČĻ]&IuÍw‘,JG/9Vl¤rÄ^‹ IĘLÃv6UĄŧņHLõ"ŰĐB õڔô\3á€Uá/Ŧ,äųyĘeÁķ .¸`×$ „ĘxÔI*Ė9âš)+%ŊāPĢķc~¸ŽPŠi‚|qC [Ŧ¤‚œ×Ü0ĪܙŋdŒyįߊÎÜÁJ8&R‚˜#ääƒķnH†aŖŒžÉ^átŧ;ŧúZunÍ÷ĒÎܑSέš}Q}BA‘딃gƒÅí‚ c/ËW\Qy掇 Qá‘PNlļÎá!WŠ=Ÿ‘ŸÔ9ĶךėĻ;‡ĮĶˆÜ*dã ΰķwÎ3žm7Ķņm‹ŧADÁĢÎP ˙Ē’7tÎĒĪ´ĒÎy3ėVÉæ| mÕē#Į>÷šĪ%cK•ŧg$kJéUžÔ"5M>h×ā‡ŅŠŋÉĘŗ"$eb b$L­ŗØŦ•¤spTęÜ2RĶų›Gx:‹ĨdĪ ¯Í°ĪÜ 9˛éQX§:sįE!“ˆšĀ{ĸ°ė!įˆ9ŜĮCˆ…ēŧ΄›ņę0x %UįÖÔ[uæNØp&yŧŗūī| ļ EČÔ[ž€@˜¯9UuæÎƉą ōÖđŽÜ~ûísŽ—Gv(ûHĢbŨ3}åõÃ0ĸ_SÃãEá BT`ÄŽČ 1Œ*Ū΃—/Y€¯Īt+ÆK÷ÜsĪ)Ī9Ēßûĩ‘Rƒ¤Va īĻųëUž JžÕ­§ ōĨs.ŽķŒoäÍtg|Û"oœ[Š:CMÎTÉ{QÕįöĸĒsŪH{•læaŽ*ôIrĖž&”­JŪ7é q¯ō%HM]É8†įy7lĮÎëx ÆĐÔxeƒ¨sĐn6’ë`>;å\‹0*D`ØgîÖ~Jlˇ3wBƄĀ:“Á#ƒ\CåEđ™ĢČy…ių pĢMžÍĐôtÉ…Æ™A’ĒskŨÎÜņ MĻ)¤ 9Ę&î]ÛmŸįķŧīŗ÷Ŋîĩ~k­k]ŋu]ëēēŨšŖč:íwßĪĻĮĸD™vņŸOĩģîyp—R¸1ą ¸×R ë WË(¤ķŪK‘”ē;w.ĮSĘmžNŨŅcũ@ÜšĶvõëbÂÅvuwîX^X“Œ=w3÷ߐ ŠģSZsQŸ@ĘĮ™îôÍ4ˇšhôē‡'8Ŋ9„1Å D6H÷ƒ¸ņ–°Î¤‹+`]á2G9đŒ;3ŨsT?ĢOic–N÷Ûxį¯_Ĩc\’sœōĨŗĪãžã;)ōĻ×ßI‘7ČKŨj‡:uō†œíöwVšē{ŪŨd3ŲYW`Ë‘L$Ûęäũ¸ÖjŨ{û•/H*ŽAQ@`˛āRÅl]•¯ŗ'ã"5mŧČÛī(ˇáÎ÷( ąĐæM Kpŋ÷ÖԋÄpß*.zuīō–›~ī čĖTī´!vî8Ųø;ī÷Ít§Ģ‡@…+—¨†ÕŌyeȝ&!Žgî- ĖĐkŨôs?°ŧˇé=ĮnXļíÎ_ŋ҉šĖÍA~w’åK'ķIŪô3Ī'AŪÔŨĄî&oöô’Cus S6ŗØ; é,ܓ˜¤Ō¯|iDj0Ĩš&j›$Ŗ­Ā4!āĘ`íU‚ÔôB¨]ŸģÛ!\30wgú%íęÅāZíÂŨœÎÂ ą™ŠČŗ#J‘S}‘0ũ[ūŽ^û^ÜLų.šŸļ–i"5ãÄ8äÍÂčCۈj99Ô9/XáŨOė,ˆ&9IĨ_ųԈÔ8‰…š=J LČ RĶOŠictĸ~đ÷w(×dތRŽ4i×´~wÔrĻ_ųԘÔ —DĒŠ&ĸœÖ‹~“Œ€Kã"E MÛoŠft"QĀJčĮ¸ÉwûÁT^ŧDŦiZD_āœsÎiô¨>xNōĘjéÕˇnĪ5zy/‹ū% Aˇ‹Ē.ųKˇķÎ;Īéĩ.ČĘõ°ÖZkåĐŌuŽ'Ÿ|rÎ1ã΍p×B7Or*Øū,ôõ(KŋJĮ(ÛT}× å‹ "ã•Ü#3õĢÉwûŇ{¤¨|2Á7-ŗ‘+Ĩ÷2{õ­ÛsMÛÜëûŖ’+ŊÚ1_>ĩœéWžĖŠÔ”Aŗųˆš%ڇ€“ų5š–q‘ša^ä•ĩYŌJš[z•&ßíU—Ī)SHMŋ–˛jŗQ><ߍœôę[H¨>đ=m.a‘´ä“ŠĢK´ JÅŠŌ šä2jeŖ`Õotĸqa;hųÂ2ā"ļHUŊJ“īöĒĢ|ŽÔŅ˙Ô§>ĩßG|o6rĨ9éՎAɕÆ`OéŖ–3ũʗ9‘wk,( |øėÛŖĀx`âwaœeæq{\zÃŪ0̆L#Š!ŸČ)2J‚J ¯|+’ē ^-åģÜlY;Ü%t’/oŠĖöržČ #É&›¤}öŲ'įXšÄ“[oŊuNDI>VIŧ#6WšNäšŅ–˙øĮ9GÍûŪ÷žœE–ėC9$×MŲ6ŽUKņÕ.ųh(ėȒŋ9`b™ ˜Ë3Ä*e>(ūļéĻ›f9 ‰ā´åØcM"áėŊ÷Ū9Q'|ęžĢâŗŲf›e˟:„\ö,åJžŽ^"°)Ū#˛ŋą|å+_ÉÁ .ēčĸŒ'KÍĄ‡š­6ŠÜ:ōŊD#!¨>Pž$ųŦÃIÛYY„|–sË-ˇ\ĐLɊ…e†íßS÷ŽK.š$ãÍ­ēļ€Ÿp š>›÷l=Œŋ‹Ō"Ėi끘ĮÁØû÷Ë^ö˛…æ’1ĨÔÁäío{&kÚ*ŋčF,Cæˆŧ%;î¸cÎn Ŋ[pöo]vŲ%˙§mîOkŽrNh[ ŗ-גyŅkŽĪJ@ty¨ß‹ŧƒ|g“ēMjä2Væ'ī2@’^ųšöÜsΜ8ĩ”ęw)gwŪygžæ§15—Č€&rĨsŅÚĩnČidŲ( zš"ö’+æ9N&žxâ‰9úŸ|QB [›č>æ1ÉŸK&éŊ@ ˇ^p°ÖÉ;ëĮz2ĩĶüí|Ž3a7šĸî•VZ)¯3Ō:#;ēÉõZcÚŽo{ÛÛ˛Ėî”+u8™ÃŊ֌õWWŋCūΞ“Ĩd˜qųÃūsu‘[r`Õɇuī/Ūō}‘ĨæL5"#9ߏlęÖFō[}ÎBnŦ¸/÷#g´g&YÜdÎôŨ~å˜HĻ}&ģĩÅcAXÔ ×‹’°3ūū‰K‡ĀaëBFa‘M€r-QálʸHÍ0Ŗˇ+J„ÍėŧķβĨ˛(áĢō]‡6”Cßeå‘\Q†į+¯ŧ2+#6Y!€š+Q\å4ąĄ’}ęĨ¨z‡Î&LņĄĐ×"`ŗ'3Ÿ÷ŧį南rNšfMBZžūô§įü(ÚS%54R˜X2(ŧȃĔ6|›?Š+’#8„Ώs]wŨuš.ũÅi°ŪqĮiũõ×OˇÜrKZc5jŸĢÎ#íĻĖpmsRLč› 6_|qNļ‰ĒĶŧ¤xÛāAšGčlā” ŸšķĨm=F„„2%ÛvN6]¤JÛáA!”P¸ÁPß)cÆŠî]Ú-ų'7ÍBj„göŦļ*ˆ’*á(KE@´Õ8à Ûnģí¸fŧ$JĨÄJ¤GiķÅx#d”Cyėßq(áûÆČØŲgŊĶ÷ádūh“$Ą˙ÖW„ æ‚ÜkŽĪF>t{Ļ_ĨcīlR× åKÕíʸ9 "\eŽ8âˆÍĢ~ˇ(­…0/$‡bØDŽ”uh=IL¯°Ŧ}Jp!5H˝^rEčrwĨ%ß%ŦckÁo2°kko÷ŨwĪJ0yįwqÃ#ŋ8´ąîĖûŗĪ>{ŠŠ>g]ô#WČHŪOŌ7æ8¤¨“+ä+9DöąL[Gđõī"Wĸ:œŒ[¯5ŖŨuõÛ:ûî˙rÂhˇuK~ĀX^Ž:Ųqųå—ßīũúhŊĢC .nĖUō C$˛ŲD?čÖFķĮŸũËø{O?rĻ3Éâ&k´¤fP ŽzƏ€S§”Qæ7ã"5ÃŧČ[”yî)ãN—(Ũ„wgä*ŠąŌŗũöÛgâ°îēëæ ŅZĄĀP*r.ąÄųß~SB%*ĻÜS‚mH,?6VdpjOĄV ßņŠ*ĸPtœūvŪŠĄ€}ôŅI„ ĨÛ°lĘúĀr‚hHđÆâÞô šB*Ę]+ßA‚ljuĪõŖ|xRUwŠ"ņž÷ŧ'[žX .H KÅwžķ|ŌÍzEY¸đ ķ}â~Ö ' ŠBČ:˛á†fRX-”*V 'ĨHNŨģÆ5!5Ŧ6°Rŧ×iŠM]1ŒG5Q', ĨŸ‚Ŗ'Ū”Nķá‚’Š8ųmŽh;ŧŠrlŽQŽeƒĢJ­ēāŦPnl8™î5×)éúN4Čw6ŠkĐōĨJTXäŦG‡UNˇ­E'ŨĨt’ãwđÁįÍ[¤š%Љ\1÷Í_Ąú)íž7Ī>ō‘,°ŌŸ{îšy]ŋüå/ī)W†˜ŸæÂd9ÔaU]{íĩķĄōrķÍ7g âB™Ļ Rã{,ŖdĢà kN˙ĖĶÎį:eZˇÃ¤FX=Č0ōUŊúT'W(Ø_ũęWŗLu؀XąŽ8¤*rĨN{­k­Ž~ä´ŗīÆÅú,.°Ö,âä ĢNvŦ°Â ĩīˇĻí%NČēÎ{TÆĄŲä Ĩŗ–=§´ąBŌí{ũČmę%‹›ŦĶnßíWžĖŲR3ˆÆFí@ HM;ÆaÜ­˜fRcc¤(;%ĩŠR<:ī\TISÁrbī¤œÅ…"Ja´Y9Õĸ4Ûh vŠ+âDŅ@–l¤6^nZ6~DČŠ's˙Ģ_ũęLŠJa=Qœ¸Ûĕr‰ŊSā:Ļ. …ö;ɤä ˆDõn  mURŖ^Ŗ>(6ÛĨ–Z*­ŧōĘ  TŸëFj#î ÅRÃŊŦ\đˇÉûqĸĘõöČ Ĩd‰āžFąŲR̤ĻNÜjœ"ÃŪŠ´MâX,5Ú[%5Č^Ũģú!5,qÆá@rŋüå/g82JRõN…û8úR RC)cMŖā,ŊôŌ™¤˜/Ĩ˜Kę@2‘S$6ÅM˛ĸĀ‘âYHM™k” Ęp!“,†æ‚6÷šëƒ”5ũ^ää;›Ô5LRc^›Č %ÚüDpģ‘šâōYHMqĮl"WŦ'sÚܧ$ŗqŖ”’-ĨXOÖw?rÅúELž íČķˆŦ$ÃJ ä‰,Ģ’Ÿ—ĩî{‚uƒHMįs3‘šĒ\ą~XĖņęĄŌU'WX#X4šhéģ~t’šn8кך!{ëęGĘ:ûNι蛊;|Šƒn˛ŖîũeßPÜíIHf)æ@?˛ áŽk#kYĢh3r^HM?rĻ—,n˛Nģ}ˇ_ų¤fhOI6[Œ=ĘüF`\¤fĐ҉ǪXˆ W ŠŖM á°ŲR6H•ŌŠ9餓˛ea5Plē6<§›§vZv°—;5Nm˛eÄ Ļ“D§s\(6&'̝q1ķ Ĩ SāūđÜį>7î3Jw-J/rŌ‹Ôčû1Į“-#ŃBQ*QĶē‘Š‚‡ŧp'ąÁRŖ.J…;-,R˛YåŽÕɆiceŠáîÂõŠ‚ā—ÅŠXj0'ĸ”˜:œŧŸģE ‰Ô_ؕ{NÆŖJjČĩēwՑ„Ô˜i#<Ť“Ôũcé1‡Ö[oŊŒ+Z)URão”&÷šÜ |žæ5¯É'Ę,5ŦfæÃUH=Ŧ/đÕ&–ųZ” ão,}12ŸČrĨ×\¤¤ëWéä;›Ô5hųRĩžP0Í1¤€‚îMIMšRîÔhë1™ÁĶÚ wXnXXXMû‘+æŲČeÔ|+ úŌyáŋŽÔ Vȏĩđ¨ĪÁCę”iŨä RƒČ;ü€-ëŪĸw)r…\biđãÖT!5Eސģu8!Ŋ֌vÖÕīĸŗī„=†[!yNf[ÃÚ_';ĐtžŸlu§ī[ßúV–íîķØĒb¤Ļ›l"ŸēųoœÔOæ’CũȇcŊdq“u¤fhE@ °q‘šA_ä­#5Ü+(Õ6J3Ĩ˛\Ɲ#5ČI9ÁâcĖRßšœēķ!§Ø%š•„ĩ€ ‡ Š[ élSq‚náÂ…âŽ8tĘĪúŖ^J4ÅÖFÖŠhģ ĮÆĻ~ rUPōۀë,5,2e…ĨAŋē=WŒ{›“hũÔ>îX…ÔØx)ĶúΞĀúBi§tQĖ—Yf™Ü_˜"#ˆë…siA<(.ĪSôĮtâd#U'×-„Îŗ”¨j>Ÿ*ЁMŨģÔÛŌÁ |h7‚ N—z;IĶoØS õ—’ÅŌT-H ĢL9,BdĖÔ3î&ąŦPüT˜R$Í Ä†>RƌĨŽ%ĮؗŧNqÍiDW‘k.pūÖkŽRėõhīlR× åK•Ôk‚Ōlū›îrt#5:p§QĖ_ëå¯‰\ІtF’oŊõÖ|_Ī÷‡Ŧ7s™2/ú‘+æšvpõŦw|@>ôCj(ęæœÃ kņą†ú!5Ũä Rƒ˜÷ęuĮ ‰ė&W¸Ļ‘‡p%ĪŦ}lDŗČ÷Tęp˛Ļ{­‡0uõ#:}gše įšhmk7Lēɲ´îũånëŽuožU-t’š^õWĮGÍE‡Bæ‰qŗą¨÷#gį^˛¸É:íöŨ~åKXjvÔLĶHjĒÃcS¸á†ōĻP 3ˆ!tōĪŋÜrËå;5ŊŠĶv§x”‡ĒBŽV $l˜…ŸļMŦirOũTĒ!˛ŨŠą!s÷0ĻębłĀz—ęģŒ…6P@–)wJLŠŋNN§‘>ĨWéöŽēį¸Ę ÷tęžKĐ§Ēë[¯ļ šŊ*~ĸė!/ÆŖí@ôĖ%Š]ˇ/DŲwĒwĆ9×;ÛŌö@ƒ&5ũw YE¸ø´Š\AŦXX?ĢerÅ]h6é>ęä RãđÅŧļÆĒÉ?ģÉëČgä‚y‰āЧSŽÔáÔΚéVˇž{ ŌP-u˛ŖÛû}ׁÚoŠĢŋ[É;øĢŋÛüí&g´§‰,îˇũÕīõ+_‚ÔĖŨ)}&ÜĪĻt`vk\¤fĐ҉v;ž>G Šqjeū!Đ¯Ō1.dBžŒ ųšŊˇV°(ķ~åKšų;Gî×ķ“ã"5ƒžČŖ9Z\.fYšÍ íh[oũF'ÆģûŠ3äK?(ĩī;\PEeėŒRŲž–F‹†‰@ŋō%HÍ0GaÂęR3a6¤æŠ°Qm 0Å´=P@š)ž|ŅĩŠG _ų¤fę§B˙ ÷ŗūąšæoŽ‹Ô ::Ņ8ƨ\Ū&€{•&ßíU׸?įŋīn˨ —„Ēũ¨ßīûũ*ãÂläKvîHĀžŸ{<žĢTsčŒk<æú^÷?Ü;lzpŽī&š=W,Fų|ŋō%“šķÎ;īī%†ø(ī ö!@xˆ3¨"’“”Ŋʰ/ōöz˙ >yJŌDQŌz•jØå^ßíöš‹ŋ"ú¸¤<ŽbßŅG„!wD+‰C‡ŨIã\îÜģ:#Žõێj”ē~ŸŠûž\‚ˆÕ´ ‡ÕˆCMŸį÷ûN4Ž6Nƒ|ŠÃNč]ķDUėUD@ĢæČéõũnŸW#¯ÍļŽš<'ŦŗČ…ž%”ũ\ęė÷Ų&2žß:ģ}O‚SWf0f’eIũʗLj‚ŅĖuęÅķ@ Đ  5õČ ‚Ôˆļ´âŠ+ŽÔ”0¯B„"Ã"uÉO3Š2Š) /›´Ŗäšu.eûíˇĪšA$˙kZŠ"RrĐ4}~œßī÷"ī¸Ú¤&å°Îƒ"5ÂP#ã(d§Ü^Bæ īiT›Q’Ņ%uLdYR7§ú•/AjÆą"[úÎwžķųô#J 0Hú%5mˆN$Įŧ.§JDétȉģ<rH\zéĨ93…ÕzĄĖ;!•÷@â=ņũKvđ‚!K‚“EIâŠB!B˜Ų;Jn™÷ŋ˙ũŲB& §hŪ%g„Í›ō ,(Å[Nųa$k”īĸa‚)ԇvXzÜã—“č]vŲe9ĪeFŪ—j‘ÅåKn"—i‹<*>{ūퟟs¯Č•Ō­˙>ŗ‰°ÄI´§šxœ ˛|č‹ü+rJČ##w‡vËĮķũī?'ÔŖÉë Éb ųę0P|:rrxˇÄ—u–¤Fnī:ᄲåĘųAžęú^,5Hņ‚§“R Aá‰ũäˇZUr’`QrNßÎWR?ĄÂåŗâēI7õ[> JåHę|Ū\* ī¸ ÉŖn)˙vj+T­q‘ģHũÚã"õ¸KŋJĮ¸ÚŲųR×wáÍuÉSå[zÛÛŪ–äģęļæ„ÕĩÆĪ8ãŒŧū &;-5usÜŧú[ō]֒4æTŨ\ë&ģJâOí%ËäÃōū^ķŌúĩ>XvkĶÜŽ[§”y˛D¨s2Õz×g2†ž"˙ YøęWŋ:Ë]m%ģ.šä’´á†æõ*y-Ë9yH6*rPIŠ)§Žú„Ž×cP'sú‘ņdx?u‘urYâeØŲ[ČōP’Nû+œĪĢaë̞Äĩr– Ëyė`ĨČášëúÖ­=ãZŖ3Ŋˇ_ų¤ĻŖ7Ļ6Ųėà qLāOņkû%5m¸ČkŖENJĸ:§€”G˙÷›âÁ:bãAr>øÁfBƒøŒB.}ÕũĖ˙_úŌ—fō"ã÷ú믟 ›ŽHa§œrJúÔ§>•“ĘqŨB\ʍДcߡiŲ°)ÚKJŽIQCj´Sģ)B6o‰B(ĨÖļͯ—}BŽ6Ųd“œ| Š“ŦOFq¤JnžųæüģŽ˙Ŧ%y „g‹ûō‚°QļöÛoŋÄ% aĸ I8ųœį<']sÍ5iË-ˇĖ‰;Kr9}ōŦ˙×a;ØJøFą[7RÃ-îpEB(S°DëúSuk%Éæ(¯„īžx≙8˜Ú a&|ËģáEQC@‘J%ĸzßĮ{)h ™"ĪŖĸˆhú ãģŨvÛåļIāéoHĸ9į;”šq—~ŖĢm/u}§¸JœJˆ hū“-Ũšķ­ˇŪ:Ī} 9YD6TIMˇõmΘ;äˆ5‹sŨBÜëæšīÔÉ.kÁ:&„Zļ>ī^ķ)'÷XãȆūÖ­S"ä‡6[+ÖŗĖõ֟ÃrĐÁ‹5/¤Â!ŽÃDYŗ&aCîĀS! ŠH4Jî9œ"Å:™ƒpõ’ņČU?u‘[urYûČÉį=īyYîŗĐhˇ~#`ˆXõžfU–ÁäY lžųæy~ vE–”Ņ{JˇöŒkÎôŪ~åKš6ŽŪ˜Ú¤fLĀOųk'‰Ô85%`ą PBŋņodeÖQ,™6Šē,â‡rHļ°Pœ%Iė$5Nũ„ÚYŖ‘Ё—•ÄߐuPĸļI¤čR*eÅ ‹ËO,â#Úä´ÖiįÚk¯AAp Š Eĸz9˜RCŠĐ7…aũ;!öäNņ.$ËĻ^×īÕĘ9…Âû!‰7‹ •ŋSHz‘ šŧ(dĀ•1„‘ļ#GŨHzāYČrĮTHSgߍ%ŌŠ&őˇĶbĘFų.BA!Ŗ֑Ę]7÷3Šˆē9ĸîų*Šņ=ŸuÖY'ĨËé-B¤ßæ…ņĄäĩ!ÜmŋyĮ%îÚJjŦņ¯~õĢؚ‰ŧ"öäĐūûī_쿐ΰB(”iëĩJjē­ok@"J‡% ‹ĻõJŽÕÍ5r§Nv­ŧōĘŲÂĢ.„ą(ëąÛŧÔ'ī#øm­! uëTĻ{rŦvëjõÕWĪÖ%1pĐrMŦkŅaŠn"Õĩ IDATvYŊH Cöv“9úoö’ņHM?u!(rš"×Xķ€’Ŧ¸›ûYU–8ØbŅęFjČUõÔí)uíik‰~åKšqIØž7ÜĪZ8(SФ~IMĸQĘŊĖŗĪ>{ŠŠúĸÛ$¸DP&l 6Rn!K.šdVPĒ–›1ÂâTŗ‘ÂlČ6# *åēÂÎ̀r‹”ōĘWž2ģ •wQ乑P&§“Ná(6~¤‡BŽxQ’/ q)ųČGōIĢĪū׊v"sęPÖ]wŨlq@jęúĪ­Ģ“Ô85t’ZH ÅÂIc'Ša áÎU,5å Ĩ9rFÉZtŅEķæĪŋŊЁŠvØĖ•C=4)—žēžsWã§O÷ÜsO>!†‹ Lc‹¸u’J!­ŠaŅŖŦu’šō|•Ôč/e§Œ…÷{VŸXš(1”JH!]ãũ*ãjcäK]ߑik†k$W#֌BjęÖKŸy_Ŧ™Ŧ†NøĢ¤ĻÛúϜ×ÕIFÕÍ5Ž^u˛ ág]ĄĖÕEX™i^WÖ/e›%yĢ[§æúЧžē@YgM!WõŗŽÔ,ˇÜrYæ•õBž°€U-5Ū‰ÜK Ōä™n2§Sîv“ņpč§.2ŊS.;.tg)ĸē‘Ų3‘š"K:I 댊Ĩ†Ģ!™Vˇ§Ôĩ§ēOŒk­ÖŊˇ_ų¤ĻMŖm ρ~I͸/ōRd_|ņ|ÄFÎ:sÅWĖHj(ŠÍŸÂË*ŌiŠĄx"aūŲ„ę$5tD‰Ī4_u–îLxžØX§‘nužuÖY îÔPÚmpÅŨ’Î% 1ŗĄ•ûÚkŖc réŽ Ōa3CÔlNŊ x°(õKjXfœÄę7u‚ÛRÃ-‹r‚Ėp‰ ˜w’í놁ûM_āC¤¤u#5pvšíNnšéčŖ:ęúŽ0RėÜĸ\:ÍFę(ÆËoĸa,(MŦe\G(&ú†Ô7„ÕüŠ$Ĩ("åÔēķų*ŠĄ$ņûw‚KéZoŊõō|áˆÔiCGŸŗ¤ŗôh\mˇ|éÖoķ†õĪ5Ãåj&Rc.Zûæ5‚ĀrgŪTIMˇõMųŽ[ĮÁēšÆíĩNvi3ĸĪōiÜtĶMYöôš—Č‹ĩ>:l o­‹ēuĘԄÔ8ˆBz˛–ˁ ˆÄpsĪČ÷:IÍL2Į:ë%ã̤fĻēÁNšĖEϰŌKn€îаDųģõN.u“%ĻKrߥ ŲåŲĒû,ęö”ēöT÷‰q­Õē÷ö+_‚Ô´iÔĸ-Ā"0)¤ôm.\6[Åæ€¸ ,,"üzЁMĸdS§4/ŗĖ2ŲÂߑRœnŧņƙTpņ@Š + ĄœbRĐípšīôŸUÁi,7 mBŒŧKģl`%¤3W9īu÷‚ōā¤õ)qēË=ŖZXސ.dÜÃԇĐw'īF”¸ŖQĐëú_į~æ^M^?);H„ß Š…vyŽ2ŖO…Ô85´á+Ũ0p?ÆéŠį_|ņÅ]C:— Ü;(‰ÆÁã"X×÷jHg䁒H¤0"ˆÜY˜øē;ŨæĮī^€‹ÆN›‘w#øĒS:áZŠūRXœ¤*uĪRÃŊ^ڄX ũ1Žep¯ņ=¤šwé÷"ī¸ÚŲVRCéĻ”’)Ö-Eœ;+<ëÖܓžô¤,X!ĖIä}ÛmˇÍ2Ĩ×úî\ĮōÖŧŽ›kŨdW5¤3$8¸ĐŽ™æ%RáĐĀú%ģė d- yį:%CN;í´VR÷‘–:÷3Ę<"ĮzÅĒLæ8\AԐ#Žš +™QGjēɇ;Ŋd|'ŠéVĢo\ÖOÖZû‡BƐ'\“!#Q,qXäPJ;‘"Qā}(¤†Œ@’ęö”níםéŊũʗ 5mŊ1ĩ)ÜĪÆü”ŋļ_RĶ–čD6G§đ6gÖ›E¸[qZĘ˙™ûÃLaEņ‹_䍊ßĐŖˆë€Mŋne”]._ŊŠÍ1buꖘáaũĄ•bķ`ÁņîĒ \¯÷•Ī3d•†ŦJŦ ÅՍ’ĄTûÕ­î: ´ąC$zmqÚlüXgĒĨŽīŨęC*´ÛWąd!1W:€jŸūõōOīö|ĩî;™3ÁR(*(QnCéWéW[Û"_ęút“#Ü.áčÄŧęV¸A!ÜæôL÷КĖqīĒ›kūŪ¯ėęg^:|ŅßĒ’>Ķ:íwž°2#[Ö(’C~! Ö&LÉÃ~ōŊÔɜ~e|g[ëęę&—‹ÜՇ˛GĀÅöJlė{d’:SiŌž~qÕ÷ú•/AjŽˆ“ Ž™u.…rR|KûUræōž~ž@ũ ßiŠ@ŋ¤Ļ­y›ö7žŸōÉķĩ×^›•nr9‰2Ŋôh\„|ōŖy/Ōę^!W+Ö/nŽ,›Qρ~åËĐIđrØ#ŗ|?…Ųžųž¯Se?EXUt™ęJh=>•åŌo?utûŽú¸Sp‹(ɌøUs#hZĒu•vr‡€QJš6ŒÂôĩ!HÍôi¯9ąũÖˇž•ÃAsáVeēč÷"ī¸PR3.äG÷^>+āĘ­ˇ›…zt-Š7 ~åËĐH ßgūčūĄ\z•™ĸ|¯„îėõ_T~åÜŧ!ęôgîUGˇĪ]hua”š—ŲÔåTū‹ūéũÔ_­Ëâãˇ-ęßÉ6”p?kÃ(L_ú%5\¤Ž“‡€@%J\[ōĨŖm úC _ų24Rã2—‹K.mõKjX-ÄéA$—Ã$‚?¤‹ĻüÄY7„Ÿ˙üįų‚ŸpŅ4<‹Ô¸<éĸ˜"†ˆ,-ĸiø2ŠŌ;VæJĢ\ĘuņÎZ—ĖXŽø*ēdŠ¤É ą‘DN’隘Æ˙Ü9BĶŗŦR’ą!e.¨QÔDļ¨Öå9æ4m3XÚ*3t‰€áĸ—ļy‡wš¨Æg”˙Ŧ÷Ā4J 0 ôKjÜ?˜éîĘ$ô5ÚĖGøģۛíYm-!_Ú:2ŅŽ@`fšČ—Ą‘šŌD&Ŗ~I‹Ĩ a.Eļ§œR¯°Æˆ2#L‰$2̉h.ˉžáb/Rãģ|*E­q) (ÂyŠČ#R Ąķ~#Sž'37‚ˆ/Z"â;Ŧ*|5…Íä~†¨ˆL‚`‰JS˛ÛžtŌIųī’~‹ ã‡yĩ.õ¨Īģĩˇ\üõ]ūį"¨[Ä$Ÿ#mÚä­ v0¨f˜E´~IųܖKĪmÅ2Ú´—Û;ÃkˇŠ­!_Ú4Ņ–@ ũʗ֐V Ę:"Ã"Ŋ'‡hŨHpŖuîg\ÆÄ}nP¨R'dđI‚…‡¯ˇ=r(¸°Ôø›ˆ:ęC:ÜĨŠēŒy á…âķūãŦFH‘vŗ4!,?rˆE^­‹{\!5,P¨",žƒD ûyüņĮįö 5.Ûē3TęF^‚p?kļČâÛũ!Đ/Šą†XPųBG ÉA™Ąt´š„|iķčDہî4‘/­!5%ļgˇ$Üá&†hiÉrÁŠ!CÉĄ0Ķą×šƒŨ~ûí™4ЃŤÄD ¤FNa]jĨ\ÍDj–%Éī¸Ė éĘŊÍŊ Ö#npˆː˜é3‘!š×!YĖ÷.ēy^˙<[Ŋ#ÄzĒ4 RBŦ ~I;kŦĨB*G ÉA@b?{x["yÖ!ōeræS´4¨"ĐDžŒœÔpŨÂēN8ᄅFe‚…‚%_ČcJŽdT"™i,Á)9žWI 2â’âˇŋũí…H@!5Ŧ,˛TKX%CĢû>Bēø¯Ž^¤ÆsKÍa‡– ŌÁMŋüßŊ"1r—§ĶRŖ.–ŠbŠ‘ M„8‰ō žÉAũfí R |’č—Ôč#ë¤;lÖc”@ h?ÜēŨõ´īĩŊ„|iûEû…h*_FNj$RdH-…‹™ė˛”{nYĨŧä%/É„ÅÄe|wg”ââ…Xėŋ˙ūiŸ}öIĮsLū\–f$ĀsHŒ€îÚ 5ÜÃ|W8eŅ˙â‚ÖÔ >˛y+:!5%ŗķᇞŗm+îŧx'âÃĘâ>Œ m*i›>ŠĖVęBšsÎ9'ˇ›‹W ë+U‰æVúƒ#¤fūŽ~õĩ ֚ų;ĸįĀ ų2H4§ŖŽ‰%5Ŧ,’pŠŽôÁ~0'Ž™eß}÷Í9mžųĖgĻ<0­ĩÖZéÖ[oÍVÉ=W[mĩ´ëŽģĻM7Ũ4UI 돜5¯}íkĶ–[n™/'û‘@SΘmˇŨ6×ŋ÷Ū{§u×]7}á _ČáŖßņŽwäēž˙ũīįz%õ|ŲË^–N>ųä\šéX(Ņ‹Ų#¤föØMú“,ÖŦā‹/žx–Å—^ziēįž{rˆ|‡?Q@ ˜-!_f‹Üô>7ि‘|dZe•UŌ…^˜ļÚjĢQįIOzR)$D2N֗n¸!í¸ãŽŲē#1įå—_ž~ņ‹_¤Ûnģ-GSqáŸøDNŠŠž3Ī<3É`zĐAĨC9$! ų}ĮwäĞrČė˛Ë.éÃūpZf™eŌøÃœ@“ōæ]W\qE&7“FjÂũlzú8{¤fœčīŨ_|q> :öØcŗ|•LĄ!o÷Úk¯|ˆ´ūú돯ņæ@ ˜XBžLėĐ ĩáKj~õĢ_%Äf“M6IŸûÜįrČ×ëŽģ.]tŅE9&˛ĸHŽ)Ļ>ëÉG?úŅtķÍ7įī­ąÆé°Ã[đ=ßĩᲾȇáqįwÎߡų"@žAj>öąĨmļŲ&Î"Ãô„'ųÉOĻwŊë]9`ŠšķÎ;sB˛[nš%ģZHPæ=“FjÂũl2qÛ[¤Ļí#í @ &‰#5îŦœvÚiiÕUWMW_}užßâŌūškŽšĀÍߐ–ßáĒÆ˛R "ÚSéüéO:ߊ(@?ȏ˛õÖ[g4īAj$f=öČÖ¤æĒĢŽJ/yÉKō÷×[oŊÄßĶįĮwÜäĪ’čA 0üAD+œCú~”,d$Äž_<ÃYd‘ôˇŋũmU­Ž6âÚŒ6ŽÃ4āÚtâû@ tC€ĮŅū>)GG/¸Œ‰Q^UBX`¸Žu*&ŋqE[vŲeķŊ™~Šģ9"÷xG?…UˆÕĮž(@ ˙@ ,Á1@`X„|˛“WīǚɃē]-fĻžZØiĒ"Î8ãŒle:ëŦŗŌ‹^ôĸv5:Z‰@ÜŲ›Ča‹F@ȗ‰Ļ‘42HÍH`nįKäáaÕzđƒœCV/ŊôŌéŪ{īÍy$Xˇĸ@ 0BéŠQG Ô!ō%æEA HÍ<ž GuT:øāƒs„R]tҜŖGë(@  pŠQG Ô!ō%æEš˜Vš?ũéO ĐXląÅú@ @ ´°Ô´}„†ÜžĒĩ&Ŧ4C;ǁ@ @  Aj†ëdUZŦ5aĨ™Ŧq‹Ö“‚@¸‡LĘHE;ÉC äËäŲ°Z¤fXČNPŊŦ5tPŽ€wi&hāĸЁ„ y'd ĸ™Ā"ōemHMR3$`'ŠZŅÎvÚi§tĘ)§$.hQ@ $Ąt ͨ+Ē„|‰ųPR3įÂWŋúÕô•¯|%˙”˛Î:ë$?k¯Ŋö†ˆĀ^{핎>úč!že°U‡|,žQ[ 0,f+_‚Ô kDFPī°IL¯.Éé…P|Ū.!˙ņ˙‘Î=÷Ü&Åw@`LpAÛmˇŨŌK^ō’1ĩ ˙׆|éĢøf Đf#_‚Ô´aäúløIL¯fÉé…P|>|āĶŊ÷Ū.g1M AāOúSZjŠĨōēm{ ųŌöŠö #0ų¤ĻÅŗ¨í$ĻtArz!Ÿ\>žųæ›ĶąĮ Ā!đæ7ŋ9­°Â éo|ck[ōĨĩC fD Š| RĶĸ 5é$Ļ”Arz!4?Ũë^—Ŗņmŋũöķ„čy 0pŊâŠ+ŌŠ§žÚÚև|iíĐDÁh*_‚ÔŒqBM;‰émœ^͟Īåŋ8ũôĶ#lķüōčé” ÔķŽ;î˜ķIĩĩ„|iëČD쁙h*_‚ÔŒpFÍwĶ ę 9ŊšŪĪ×]wŨtÉ%—LoŖgĀ#ĐöõÛööMņԈŽsF Éú R3g¸ģW$fnāə~“ôôđ€ô÷ŋ˙}’šm ˙@Û×oÛÛ)ē#ĐdũŠāL 3@0kĒ ’3\|ĮY{Ą5ÎvÆģ@āū´}ũļŊ}1§@ HÍØį@˜ņÁ´“œĪūķ]ķ?\uÕUéŲĪ~öx`€oĨc€`FUĀˆhûúm{ûF<\ņē@`ĸh˛~ÃRĶ`hƒÄ4k _6’sß}÷Ĩ{îš'#šŌJ+ĨC=4ŊôĨ/Í˙_b‰%Ō"‹,2”‡ķĘ&Bk8-ˆZ@`ļ´}ũļŊ}ŗÅ=ž æMÖošfD˜É^.ĶBr~ķ›ß¤ų—I7ŨtSzŌ“ž”åųĪ~zŅ‹^”>ūņ§Ŋ÷Ū;ũâŋHtPūlë­ˇÎcÉ9˙üķĶģŪõŽtË-ˇ¤WŧâéŊī}o+“[6Z“=+ŖõĀô!ĐöõÛööMߌˆƒC Éú RSÁ=HĖā&akšT’sŅEĨÍ7ß<ũîwŋKˇ$•Ë/ŋ|ŖēëŽģĻwŋûŨ9Į‹äwüãĶCúĐtûíˇ§Ûnģ-mŧņÆéĖ3ĪĖ—đˇÚjĢ$æû&›lŌēái"´Z×øhP 0Īhûúm{ûæųô‰î3"ĐdũÎkR$f~¯¤I!9Gydēė˛Ë˛ÕE9ûėŗŗUæúë¯O˙ôO˙”õ¨GĨĪ~öŗŲ2så•WĻmļŲ&ũčG?Ę÷qô Ĩ 7Ü0?ĮJķÎwž3Ŋō•¯lŨĀ7Z­k|4(˜į´}ũļŊ}ķ|úD÷ 5ŗ™AbfƒÚüyĻ­$įe/{YZ}õÕĶūûīŸãío{ZtŅEŗ[ŲĪūķô¸Į=.Ũ{īŊ™ĀrČ!éĮ?ūqļŪ<æ1IīyĪ{ԃüāƒ¸Ųf›å„ŌŅļ‰öũ#ĐöõÛööõt|3˜4YŋSmŠ 3˙&˙ {Ü’ÃmLP€˙ūī˙Îwh”ŧāémo{[ÚtĶM“(hl°A&7\ΞûÜįσ>8!BË.ģlēöÚkĶ͟ūôtꊧĻsĪ=7]pÁƒ„h`u5Z{iTA íëˇííČ D%Ā”"ĐdũNŠ 3Ĩ3ē%ŨÉ)÷g–Ĩ—^:ũõ¯M|āĶĪ~öŗLZ~õĢ_e73AV^yåLbžôĨ/Ĩį<į9Ų’S,5+ޏbúČG>’ūõ_˙ĩ%h.܌&BkTøË_ū’ąžÅŧâĘh†Qķ2w‡Q˙¤Õųûß˙>ß}ƒų œ˙÷˙wäÁ@Ú¸~̏ļŊ}Úōfp+!äÍÂX’ŊĸĨNjÄÔ&ëwĸIM˜Á ¨Š9ã 9u­´ "€ôt–ģîē+Q ÚJfJ{›­æ#Õü î|yČC2vī{ßûŌūđ‡ėŌ7‰å‹_übžĢŽēęBÍ/A%~ũë_įhzûØĮ˛›ã Š9ŠÎŨvÛ-]|ņÅé-oyKēîēëUũœëqWMÔĀã?žq]đaE´IųÛßū–^õĒW%9¨~ųË_æ9ÖYĒãÂÂÚ¤öDîŠW\qE“fÍųģm[ŋj{ûæƒŧ!vß}÷ôŨī~7=å)O y3įU×ëŽģnÚoŋũx{t>éŽ-yíŪmK“õ;Q¤&HL§[´Š Đ’3‰#ŌDhĸßøÆ7Ōë^÷ēŧŗ‚9Uėc;ŠWü\ŨĮęŒz÷į?˙9}ûÛßNO|âĶ#ņˆ9¯z˙jŽ AϏI"2!Ģã‡>ôĄšV;°į’-ļØ"Ŋæ5¯i\§> ¯n^4)yČ5õ͟ū4ߋĢ+e\žõŦgeŌÔ¤Į{lvEĩrŌļõ;i¤f>ț;î¸#Ũyįé‘|dț&BcŽß-r‡WË,ŗLmmîá’ũîáļą4‘/­&5AbÚ8ŊĸMũ"$§_¤Rv{â20ŽâŨ'tR:á„rĀŠžÜ@üāĶ:ëŦ“ūëŋū+oīxĮ;rô9–čX!X#žüå/į;M_ûÚ×Ԑ•y}:ųä“ĶqĮ—ūđ‡įûNaĄˇ…ÖĻč˙öˇŋMo~ķ›Ķ[ßúÖ´Øb‹-Ô}uą|˙ûßĪ÷§Î8ãŒ$!Ģ`ū-āƒ÷ˆf×­ ÛnģméŊ +¤Ī|æ3iĪ=÷ĖwŦnŊõÖŦ,#lŦ”įõÖ[/[T´ĪŠžģ\Õ"ßᇞžūõ¯į{]§œrJÆdÍ5×Ėu#}æüūįĻvØ!‡!ˇ‘úžģ\ú͟ņåáôĶOO/|á Ēßß(ûų–˙øĮgLĪ:ëŦü.côä'?9}ī{ßKûėŗOúæ7ŋ™-L0 fÜē.šä’ôū÷ŋ??ĶYĘ]5$cšå–ĢÅÉĶcŋÚjĢek—~-šä’ŲĸĪkŦąFÚhŖrž(ķÅomĢ+ßúÖˇrŽ( ëߛŪôĻÚųô‰O|"Ë˜Į@ŸgXn()Ū˙ú×ŋ>Qظ/įŊæĨzGápÜ뷗Üh[ûæŖŧá-@ÆPžCŪÜ_†w“7äWˇ}¤[:{‡ŊŠŦuדˆ¨u…õØGŨö˛^kl˜Ÿ7Yŋ­"5Ab†9-ĸîq#$§û4ZÃGJ!ąĄ"”äį=īyYafš?ė°ÃōÆpĖ1Į$'Ģ\wšļÛnģô?˙ķ?ySøá˜É—@ãŊ×^{eRqà 7äõŠOMŸüä'ŗBKyĻDîsŸKßųÎw2šP˙Ūđ†|2OÉ­kƒÍģĶ~đƒö›UBûĩ…ŌŦo\_ôíSŸúT&”tŽYúW 2ō´§=-‡‚ī×N;í”]Úlē%‡’{\ˆÚŅGs$!6,^ȏ“ŽuhĩPøëō-yĪZk­••xm~ôŖÉ_ų`1 IDAT–ŖŽ:*ˇaįwÎ!ŠHG}ōÎ:‹ČO~ō“ôo˙öo™ žSãƒüy'ĨBŸí>īŧķō˙˙ũß˙=˙F ’QWĖ ¸¯ŋūúš/ÆĄÛ|2.,kĨŪoŽ"6°36æČĄ‡šÛä7Ōį7 ›Q–q¯ß^}m[ûæŖŧ‘rā _øBžķōæū2ŧ›ŧ!#ęö‘•VZŠ6yH.ŠĪoOšŸ}ôŖ­]&7ŪxcūYHV×í#˙üĪ˙Ük‰ õķ&ëwŦ¤&HĖPįATŪr‚äüc€š­AĢ“Ēg<ãY9ĨH:EuĄ’‹÷3§īNÄ(öZJ¤“/'kî.Pĸm6ÖĘ5ŞbĘĒ@é/—ˇ)Ī6›z‰fGÉõ7Šq),%Ú@áVŧĶĻÃ%ÉŋY JūĪrŌŲ÷>üáį6ŗ6ėąĮŲzÄr¤hˇŋ &Ą^'{ŠvQž)ŪĨĀD_šĢEü /Ė ^Eæ+9”$AaeđĖâ‹/ž­!žŖ¤Ÿx≹MRĩtˡdLXp8õK<‹ŧ°Vé“⎠"#ōŸß%Fˇšĸ͈ːÆNLEdI*÷^X§ŧĻ”~<á OČ}AäųbéęV`Ž­˛Ę*3Î'ã[mDV<‡—ģqģė˛KŽ|¨+ĸW"!š{Ŗž<ÎõۏíG(ƒ6 V Ž^Ŧ7î›ØXNXa¸‘}úĶŸÎŠ: eŧÜGáæ™Ą,S`ôÍéšį‘ĒüŨ†Á ĸŦ—bsaáĄ$#3”t'œŪ[ÂrSρõ§ŗ 6)dé™Ī|fvqcaAÜ(ÂÜ´ûž{îÉ ąĶōÔá˙\ēǧũž;í´Ķ2ąđ. nĨ‰Đ$Æę*„ÃoĀöÛoŸOčm8\&(—,6ä™SöË/ŋđdlā
[:¤Ņ]Bûw_„i&šdop`âbŨ^6yŌšŌšč%5ĶBb ˛ĸ —¯rįÅÖšŠ¸Ų„­†Elú~§Uŋ}Ę—§°ŗņ™lږjØJŠXŋ…‚Ä cŽ'/n# †…lœ)œ˜Ĩ–ZĒß&å{“LrœQÎ\rwJ>Ą5 đ)Ɉ—5ጂm~PPY5(Ō.ˇ"=Ü(ÜĨá‡ė´‚[ĸM‘ÂcRt÷Ũwß<¯(ŸNĖ(Ä6wŽæŸ5ĀzâŪLQ,ÜA1O)ڔvíaâĮBqõûîģīÎķØæåūK]^üâįS~ ¯į‘ rY”5CítĄTa… 'X_ȧŌũA<(ôîŠXW_}u&Xģîēk^WÚj ą$!ĸ|šC„ š?!ÆY7üϏ“Šˆ‹¨Huų–|†P8}TÔƒėą˜̰f°R°Ü¨ÁáūÖ­Ts=Ų,ë0Ĩd¨ŸĖ1ÎäDq3sĮŠsR" OÖ*}ęV ĘyKvv›OÆÅŨ™’Š’âŪĄpßAa(7šZæ˜q1îŖ.M”ŽQˇÍûÚÖžq˲Į!‡Ã˜â&;LyÃ5×=#äÍũex7yC×í#>ēåĄ#Û–c„ˆ|č&—¨‘cwČénûČ8ÖlõMÖīœHÍ´˜*xeú9ĩ QFœņÃd™MXĐq6A)“&/?pJ…„BīT“ĸĐ´4mK5le“wŲŧ‹ŲäšÎīRC¸ŠBånĨŽäÔĩ›Īé\Ū9Ėg'‰äđŲuJWŦeC•Ü4ZÃĀ”;ë ežœ´;Ĩg))D€€’ÍŌá7YŅœJ>ׄbLĻPūŨOŪØfÂÕĢ[1ī)ļbŨ˛ūpcĸėö*ˆ$%žWča}ąĄÍļš_ŧĪ)&چ¨’‹,ČLįÁ‹ |úM0Ų-ßÂã°ŦZY ,Iī.¤Šķ˚¯–:LMЧö#oƒnšå–žwUD8ĶīÎBŪ8ĄŽ–&ķÉs\ôøĘWķQ7–5uĪæ`Ē×üé÷ķq¯ß^íl[ûBŪ„ŧŠĘđnōÆAJˇ}Äœī–‡ŽL°9@˛ßˆhVWÚÎ(—ŊÖŌ8>o˛~‘ši$1äDÎiĢ ļlX1‡ē[QŒÚ&čDÔ%O§¨X˛SO‘”lj\3L"§iNø(uN4šOpÍ 9eäĸ`BRüLvíprĢ~—ÜH:í­ +ú’JFYį’Ar™øÕ¯~u&iĨ8ĨXÉ6_×7'€Ũ––ļxG]¸Ņna+‘*łĨėōãwú gmwŌë˙ĖĻ\]øâķÉu:Ém~Nļ}—˛Aqs2Áˆđo§ 6§ŪáR¯âNŖ‘“§ŋJš@=ŽE;ˆwļä˜Kˆ°B9#¨ĖqķČiû8C:wâO։0…ˆi_Ķb­ŗŪXŗŦ5Ŧ7m0ã7íĮ¤~ā^ĶYĖģ~f’+\ʸxh@&õcaŲaIë,,rö€i-M”Žq`Đöö…ŧĮŦÜ;‡%oą8´Ą?Õúßŧ%5ˇ×^{m6wÛŠ"8¸iŅŽš( H…$q”âë¯ŋ>+#ĖwˆB]QŠKž;î¸cvÅ@€üÛå,…ň%€ģeĮé#Bã",ŋVßuÚÆrÂÕ Ö—^zi&ܯ+eŪÆé4°.,ŦĶ?ī.Ä!AƸ°P‹šš mķvúJÁ¯ë›ēē…--mAĘę"-uarK¨X—\E6ĸđyB¨.Ž$Ü}„"„“‹ÆÆnŦgc'Ž&ƅ+B"ĪR*ú’Ķu“āI)Aœ|9„[)Ú =Åŋ¸ŗp0­āšÄMĮ\b]`‰h[a‰āNÃMŠM¤Æüq˜PĸM5ōuÄ ?Ėn÷ŠĻõÄ÷ĮƒyA&øíkR¯Ž Ŋļˇ/äͨfj;ßĶMŪÄ>ōãÕdũ6˛Ô´ũ$xĶUdŠŋu ŸtŠąSļnaDÄâj!ä§ßˆ‘‘u€ë‚đ{%Œ*ëWäuŸ: ś+Ke_"9'„,<%,"IV—ē°°ˆĶÅr*ĖBŖ Æâ­ĨčŸĶržēŠŪSļĄđą8q˙č 7ĘŊF_ęÂäRō„‚K˜8éŠėĊÅ9ĄčrŨŨŠayB]ŌFŦYˆ+˙æĘC.î¸č\.gûģ6;™(¤1BؐÄē}ƒ˜OŖŦŖí–ÔĒĨÁŽZjœ ˇ‰ÔŒrÜâ]Ā¤#ĐDéG_ÛŪžq`ī &&롊é`I]Dd; KF]Q–Jš‹[ŠËļˆBā´ÜI9‹‹Ë¸Nü\ÜuúĮŠ7ĨžXcœŗ>”p, ž-š‘– 'ŋuaa)îȔ$vˆ ķ#‹(H%JS!ŦL§Ú\×7.]uaKõE[D⊠7ĒˆX]˜\§ķåũŦ/Åß~Ŧ5Üã>dĨJjme}pi)rׯĨn!yŊ¯—‹Ŗ~šh§C‹RÃd|ēų›ļ}ÁˇÄTņ+wjĖBfDÅ*Ĩ‰Đjû¸DûR^÷Jŋ÷i& 3ķXßĖÛ(ÍNRĮWȗq >ŧw†|ļmŦšÉúŠ™6’ãĸ,7q;/uę+RSF”ōíū‡ģ3%WƒßŦ$Ŧ/,ÜŌ(áĸqË*aAYuDļá{OiqaĨážæb,rÄĸ#úÅėōr]XX÷rÔ[Ūí>ˇ,īķċĸīr)R w…{9ˆPˇžąlԅ-e Ņš!ę i+2T]˜Ü2oāQ%5ˆ¤~<Ž*Šáʆ°q'Cū´ŲķÂíʍÁšÃ] 9ƒ!ë˜ū"„îŨ¸h ŖBjX™¸˛MŠkĐ$‘˜NŲĀo×ŧũŦJfÚBjĖ%˛Ø•(_Eų-ũ´Ž]­‹7›ÍÄũ=rÂa@›Š~˛Jŗ4ĢôŠŠH.8Øq˙pĐÅa‰|DB\ŗ8´qįą` ķ]sŠģ‰Ō1—÷ĖöŲqˇ/äËlGŽūš/ƒÁsåË@Iͤ“œfŗ[Õē0ĸG ŊÉí @`l¨Šš(ų.L"wJšû6, Š‹a ˇ(E¤A(ė”u÷r(ûuaaÕW}7å‘Kû26an]Ĩ `Ŧ,îJ)u}Ŗāu [ZB4rĢ 7ĘÅ­.lešœī¤†bĻũ0ƒ ĩS;`Yņ^n~%´.…9a-]Ęŋ‘5 DŽã螎(F0ÂēD9ŖÄú)ĄZ#&WË$“˜NvķĄ[ˇŌáĢf5ŌØ\F˛šjĀõ!ÚäÅ îh¸kg]ĖGRĶ+*" ˇÃÄoĐeT¤)v˜UĩBē/ƒ¨oÜëˇWÆŨž/ŊF¨ŲįŖ 5!_šÉ0ŋŨdũ•ÔL:ÉŠ¤™ÂˆÎvPŨ•A>Ē—’Ũ›qyĐ]žę˜ōŽ~ÃÂ6iĶ\úVn´.le¯ö°Ė°Ē sB8t†ŽŊéĻ›ōŊŖjhSõē#ÂãŽR)ÚPBāvKŠ×ĢMŖú|šHLS˚­ĻuWŋon!ŊNų^Ğĩ‘EUô=kĩTÄ=„]@!ĪŨ9cũcmr AãR'T˛ĀNíŦŦŨH —MÖ`A0L°âJ¤æŊîŧ܉c%E´ŌĐ.‡9ôAǤF@÷ū¸ŒÂ“UŲ;÷æXlYNKHîŖ%™dgōYDßá…š{ĒÃc˜ą| ~ā€åÔSOÍũ×&3ÜjåÂŅGíŗîDCä$tFëŒ~ˇē(†,ĪucĸūnQŨäjJ~hŋŸē~šoXĀ8SāÅåÚA–dĨæAÕRĶŲø×9U,÷ĸĸ LãāFˆ}¸Á×}D3äkš~ú›w˛ô?vŽŗŗ:Ÿcą7§Yôõ[Ũōr.ë¨ķŲQ­ßŲļyTí ųō fCžüŸÜ ų2ÛģđsMÖīHIÍ4’œÁ YÔ2˜Ī$f\JōAÉcáã–HŲˇņ÷Ęße—]–•wßĨˆŗrŖd!õ‹Žā\!?vœ˛‹4Ģ‘BÜ]´NKMq?„[%žk(%—{$ĸÂŊÔ!ĸ 4+$B"<ģúš¯q­rįŽĨFCwҊĸ­>…[,+'KŽCŠ-rÂ咅Ų¨ēg‰Č á]…ÔpDÖ¸¯j Ģ3ō‚ôÉo…¤ŠSß)ÚŦĪp‚3rH‰G š÷VÜvF?Ô¯ē(†úØmLēEEtwP›$T=ķĖ3ŗ[j]ŋā[ĀØ­ đ3Æîā™7đŨŽJj:ûÃEˇs <áÂõWđdÖtŸ“"„Œ7AP¸Ë÷3IBĖÍ=í@dëžS7„„#GHč0ķØ4Q:Æ!ëGÕž/!_Ēr3äË`V{“õ;VR$g0ĩLAbēSĄ5—ŅĻėRē#u2B wo‹ĩÃÉšS~ŸRCY/šOļß~ûė„<ˆ˛‡ ąæxö /˟õCjŧƒ%–Ģ"āž7VĘŽS_¤ÃI<댌õNím÷nßA”¸]Rtũ­$YĐŠžMVYX<œĸ {Ž”7ׂ)…[[J2^„‚ĢĢ ÂcĢG°u +q”$ŦĨƒĨGß*…ĩĩXĘģ:Ŗv‹bˆ¸t“™ĸ"VŨĪēõËŨŋjÆnm@ ;î°,ÚĸõՑšR×Lc ™ą4†î%š?~#!úŖpFĘ$ķ,¤æ,yŦ3Bôģ¯$  ×åÎįŒ•ú$„wØeTëwļũUûBž„|1G‹Ü ų2ÛģđsMÖoĢHMœÁL€¨Ĩ‰éš­ūk­˙Ļ; Nš)"XQDŠĨi 4–; ”qw˛Xj(öžS¸ƒ9Ĩwú/“Ģe•Ü/Š)aŦÖs1Ō&ЍvޘBËs-rjīTž´ËŨ2ŽM…Ô°öˆn(`Ã"ã”ŋšXĩ…2Ž<šãTĸ*V“J"u—_~yNŌ[ Åä"ĮBÒāy÷ڄXg ņ…۝‚T”œV–j nruQ Ŋ¯Û˜PæģEE4VåNMˇ~!ũ´y3>Č.—0î]Öwˇū° un€Ĩ.ãĪq†mõÎ!˛ËŠVH lYÅXí"D–åĖXu>ĮmŠaY rØe”ëw6}eûBž„|1GÉ͐/ŗY­÷ĻÉúm5Š ’3˜ ĩŒ 1ŗĮš‰Đšũ[ū/‡7*JŽû,P'ß,5ÜÁ(Ē”DŽIÅRSGjX&¸qO*!ԛXjęH÷i‚¤PjY`$ī“S|€[Ĩûˇ.F,/ÂÉ#gîTpsCR¸´qáBœūsCŠXĸĒ 1‡°Qœ4`ą‘Ā—‹“|]îˇ°Ā¸ $”:Ë YoX˜´[Ž(V˜Ļ¤†5Ļ.ŠĄąę6&HMˇ¨ˆˇß~{žīã§[ŋDšŦ’šnmpŸJq_Č:Grę,5ÕēęÆĀØi/2í]&WGAj?ųš¸!rÃcũCJĒŅΌŠņC¤ŊËXÔ=į^UšHА/˙84 ų˛p”Ԑ/˙ˆŽ8 ōeĸHMœš¨sņė 38DGĨtpåĸRæY4(ûŦ ÅRCŲįNåū„$ąˆE[ô"úYKw(ڔ{dŠŧ; ŦHŽįšŸÕ…tæÎÅ}˛ZH RÂJãGĄ"rå†nX\Ö ŊāŦBՐÎú†Ô˜—Ú‚püėg?Ë'úŦ)ō4éešEDÛDfŒ¤VÁ  0ÃqBY))îW\׸Ę9•æ…l ĐÁęÃÄ%ÎŊ"Š7‹ 2VJgôCą.ŠĄžw“™ĸ"RŪX)BwŖęúår•ˆtk2Â:÷¨``.ĖÔd˛n ĘDÕ/í4ŋ$BVˇÂ Ķ*Š1T UŨsŦ†ærÄuqØeTëwļũUûBž„|ŠĘ͐/ŗ]ą ?×dũN4Š ’3˜ ĩô‡@˜ūpšÍˇš­ŲÔ_}†2OQĻĖwFūrŅžU ž‹íJJfI[÷nõš§‚„(žCh]ÜĶ`ŅĻĒ[ŲLīqĪĮ÷ĢĄĒ)îúT—‹ĢԅŧpwĄZDjDʐ&Ÿk‡ČhžĪú tú\KgÃ~Į¤.*"÷<…ÛŌ­_mŽ‹¤¨.wUāÖäŌ}ŨpKŪŋ3´wcs°É;fûÜ\ĮĒbLƇ}ŋonËúíÖŪļ´/ærŋ3jtߋ1Öŗ}S“õ;Õ¤&HÎl§Đü|.HĖøÆŊ‰Đv+āVå´Møb–ˆ(ãE Ædŧø÷z{›Öo][ÛÔž˜ËŊfĶč?1=æMŪØdũÎ+R$§É4šūī‰iĪ7Zíiu´$ ĐöõÛööÅ, î4YŋķšÔə_Ë(HL{Įģ‰Đjo/ĸeĀüD íëˇí훟ŗ&zô‡@“õ¤fLE0*?b‘ŽĘĪÚk¯ŨßhÄˇÆ†@˜ąAßøÅM„VãĘã@ *m_ŋmoßP'*&&ë7HMƒÁ’ĶŦ1|5HĖ@Đ+›­Ŋ2Ē !ĐöõÛöö hĸš@`*h˛~ƒÔĖa əxx4HĖ@lIM„VKšÍ˙@Û×oÛÛ)ē#ĐdũŠāL ’3@0kĒ 3\|ĮY{Ą5ÎvÆģ@āū´}ũļŊ}1§@ HMëį@œš Q˜šá7IO‡Ō1IŖm F íëˇíí‹ųAj&nəyȂÄLܔXƒ×]wŨtÉ%— Ŧž¨(F‡@Û×oÛÛ7ē‘Š7“‡@“õîgcßųNr‚ÄŒqōĩėÕĢŦ˛J:ũôĶĶĘ+¯Ü˛–Es@`&$ĒŨqĮĶÕW_ŨZ Bž´vhĸaĀŒ4•/AjZ4ĄĻä‰iŅdkYSvØa‡´ÖZkĨ×Ŋîu-kY4'fBā?ūã?ŌW\‘N=õÔÖōĨĩC fD Š| RĶâ 5é$'HL‹'W˚ö| Ũ|ķÍé=īyOËZÍ ™xķ›ßœVXa…ôÆ7žąĩ@…|iíĐDÁh*_‚ÔLЄj;É 3A“Š…M]ląÅŌŨwߝ]tŅļ.šüņLË,ŗLēįž{ZNȗÖQ40XŲȗ 5<‰ÆMr‚ÄLđäiaĶ?÷šĪĨO<1ūų-l]4):ØhŖŌ[Ūō–´á†ļœ/­ĸh` °ŗ‘/AjĻh ›ä‰™ĸÉŌŌŽėˇß~éžûîKĮ{lK[Í  3}čCĶa‡61€„|™˜ĄŠ†Îsf+_‚ÔLņę+É 3œŖÅ]Ûwß}ĶwžķtöŲg§=čA-ni4-˜p Ų|ķÍĶŗžõŦ‰"4e¤BžĖŋ9=žæ*_‚ÔLÎXĪšĨŊHN˜9C  .¸ mąÅiįwNĪxÆ3ŌĒĢŽš„eŖG@XU!›ŋũío§SN9%sÎ9árÖ Š/ŖŸCņÆ@ ƒ”/Ajæņ<Ģ’œģîē+-ĩÔRiuÖYđ3Ą‰Žˇ}čCY™ōŗÄK$s6J Œ{Âī~÷ģ|°°ÚjĢĨ]wŨut/ō›Bž ā¨>čĀ åKš˜r<āéī˙{ Ā@øË_ū’^ûÚ×Ļ3Î8#=đhŨQY ĖoBžĖīņīė}š˜}“ávūđ‡ß1>ĘGuT-’—]vY’øėÆo ¤@`"pÄG¤wžķé ƒJûīŋ˙“=ôĐ´ÖZkĨ×ŋūõYąyôŖöÜsĪô”§<%ģŽš„*ŌÕkŦ‘ŪũîwO.Ņō@ X€@9E-kMLŽ@ !_…äôÔ¤fzÆrN=é‡Ô° lŗÍ6™ˆ”˛Ųf›e‚rÅWäpŸ?úŅŌ1Į“.ŧđŠ÷ą}lú͟ū”Ãķūô§?MoxÃ2éY~ųå3yqyx=öČÕ}ūķŸĪÖY媁Ād#P=E-= kÍdi´>h !_Ú2íjGšvĮØZĶ‹ÔÜvÛmiŲe—Mŋüå/īįvôŅG§ĶN;-ßárvûíˇ§ĢŽē*'gCxî¸ãŽœLņÜsĪÍäå7ŋųMļĘŦˇŪz™ü”"ĄÛŗŸũėąá/Á €Āüķ?˙sZ|ņÅ͝ũëôČG>2ũáHĸŨwß}ƒyIÔ큐/ķrØ{v:HMOˆæĮz‘šķĪ??į'`mŠ–ŋūõ¯9LĢ;1’#nĩÕVéēëŽË~˙ûßg÷4yD?Bf–[nšlÕyŅ‹^”­4ģíļ[ēōĘ+sØWčxÄü=zL)îŪmšå–éø@ÚvÛm„Œ˙ØĮ>–vß}÷ėĒēÁLiīŖ[@ 0LBž ŨÉŽ;HÍdßĀZß‹Ô (ĸž}üãŋß;7Ązo IDATÚhŖ$#ė’K.™wž|ōÉ9Yĸ𭈠ëû4+ޏb~Ö]Vwm(8 _{ÁXnĸĀt!ĐKžLWoŖ7@ 0JBžŒívŋ+HMģĮgd­›ĢP¸õÖ[ķũî&wŨuWÎūžČ"‹,h˙wŪ™]ŌVXa……ūÎ å'?ųIz⟘Ÿ͇Ā\åËô!= A!ōePHN~=Aj& ԃ 1* BžÄ´a!ōeXČN^ŊAj&oĖŌâ/}éKŲįũøãĪ.bE(”hdōɸķ%š"ЏŌq÷Ũw§‡?üá÷{Õžûî›Ãž×ųąvØa‡tã7Îĩ‰ņ| L(ũȗ íZ4ģ!Aj6M_ãË׃üāléĨ—N÷Ū{oŽN™ŋ§i¤Ŗ/ĀxčGé¸ôŌKĶÆoœīîų~){ØÃŌCōÚwÜqéúë¯ĪI{ŖĀüD ų2?‘™ŊR3˙Æ|A~|đÁ …Wua˙CIûíˇß~Ũu×M›nēi:†ķškŽ™N8á„ôä'?9}ī{ßKûėŗOŽÎ¸úęĢgō#ßV”@ ˜ú‘/ĶĶÛčÉLŠ™įķƒ•FrĖRÄ~¯ūžÃŨ ЏŌņŠWŧ"]{íĩé…/|á‚7î¸ãŽ9$üĶžö´zĩÕVËÉ{wÚi§´ŨvÛĨĮ?ūņ9ŠâĄ‡š#,žūõ¯Ox`N,ĘĸDÁoV]uÕ´ķÎ;įÜX’ūF éA ų2=ŊžЉ9ЁĒĩ&Ŧ41Q@`ôŖt° lŗÍ6™ˆ”˛Ųf›e‚"•ÜVō_sĖ1é /ĖI{E\tŗÅ[äZBÅ#=Ë/ŋ|&/厠ú$ũeŨ9ņćŅŨ3Ƅ@?ōeLM‹×ށ°ÔŒđ6žŽXkÂJͯ҉6“@/ĨãļÛnËųŦ~ųË_ŪĪ=ėčŖN§vZ&,žÃåLxøĢŽē*vØa™đ¸xöŲg§sĪ=7“I~YeäŊB~JyÖŗž•žũėgO> Ņƒ@ X€@/ųPÍ‚Ô˟ąžŅZsĐA% 6ã.MLˆ@ 4Ŋ”ŽķĪ??íēëŽŲÚR-ũë_ĶøĀ|'f•UVI[mĩUēîēë˛Å†ŧúũīŸŨĶ~÷ģß%2 ™Ynšå˛UGôÆ=öØ#íļÛnéĘ+¯ĖQ G<âƒî^ÔcD —|cĶâÕ#F H͈oãëD;ãŖîō-´(@  ^J‚"ęŲĮ?ūņûŊvŖ6J×\sMZrÉ%Ķ:ëŦ“N>ųätõÕW§ũ÷ß?Ö÷iV\qÅüŦģ4Ŧ:îÚėžûîųoäųÆr%Ļ ^ōeēzŊ™ 9“Qfl0.xÚtžō•¯â@ 0B(zŋũíoķIļŸĸȍ° ņĒ@ā~|õĢ_ÍûsÕĪÚk¯Ũą[oŊ5ߟ‚ūŽģîJK,ąDZd‘EÔqįwf—´VXaĄŋßwß}é'?ųIz⟘ŸTũ%æC 0^­ŋĖšÔœwŪyųr&ķ>Ÿå•W^9˙D Ņ#āPÁiöwŋûŨÎöœsÎÉ9?ĸŖB JbÄĨŋ ÁņÛw̟5%9ŖęSŧg:ũe:Į5z5™ R™ŠŲ{īŊĶM7Ũ”/fÆé×dNĸhõô"ātzķÍ7· Gyäôv4z6Vz‘˜^ ’Ķ Ąø|„ū2 TŖÎ@`0ĖUiLjf˙ˆõ?˜ŒZa!°×^{åCŅŖĸsE`Ž$Ļ×ûƒäôB(>Ÿ+ĄŋĖÁx> ŗÕ_‘&[™…ÍŒíG€ ščOrwD š 0lĶĢ-Arz!Ÿ7A ô—&hÅwņ#0ũĨŠZķŪ{ī —ŗņu´ č ‰ —ZjŠŧnŖ3!0nĶkt‚äôB(>Ÿ Đ_b~“…Ālô—žIËĮ7ß|s:öØc' •hm 0Īxķ›ßœŖBŊņoœįHD÷Ģ´Äô­ 9ŊŠĪ ĄŋÄ\&ĻúKߤæu¯{]ŽfŗũöÛO&2Ņę@`ž"ĀeTŌÁSO=už"Ũ†Ā¤“˜^Ŗ$§Bķ÷ķĐ_æīØGĪ'ĻúKߤFū‹ĶO?=Â6OöüˆÖĪC„zŪqĮs>Š(ķi'1ŊF2HN/„æĪįĄŋĖŸąŽžNMõ—žIÍē뮛.šä’éB+zĖbũN˙@ĪwĶk„ƒäôBhz?ų7Ŋc=›~šŦßžIÍđ€ô÷ŋ˙}úŅ‹Sˆ@ŦßéÔ 1sĶ 9sÃo’žų7IŖm F Éú Rŗ'˜4 ķŽ‰ėb˜á[œáâ;ÎÚCūũxw 07šŦß 5sÃ:ž&&Ba":4$fŧƒ<í$įķŸ˙|×üUW]uUzöŗŸ=ŪāÛCū ˍ*1MÖīŧ!5˙ûŋ˙›~ō“Ÿäœ˙ō/˙R;$ûÛߒYØĮ]ūō—ŋ$qõ§ĩ´šæŠE´Č"‹L üM„ÂÔtzÂ:$ĻŨ6m$įžûîK÷ÜsO}Ĩ•VJ‡zhzéK_š˙ŋÄK„ük÷tLwŨuWÖg†U†ąG˙õ¯M˙ôO˙”÷×ēŌ&lX¸ö[/=D×$—AĖŖ&úËŧ 5ī˙ûĶ[Ūō–ô°‡=,Ũ}÷Ũ9gĮ§>õŠôĖg>3ũöˇŋM˙õ_˙•vŪyįtÎ9įä°ˇ\pÁXæĐu/{Ų˒…˙čG?z w˜žøÅ/æēlZ{îšgzĖc“Ūūöˇ7ęßwŋûŨœŖHv×ŲÖŅų‡?üáéÛßūvú×ũ×FmŠ~ųį?˙yzęSŸš~˙ûßĪēŽēßúÖˇĻûˇKozĶ›Zī8+k"ÆŲÎųôî 1“=ÚĶBr~ķ›ß䃞›nē)=éIOʃōüį??ŊčE/J˙øĮĶŪ{ī~ņ‹_¤ƒ:(ļõÖ[įŋąäœūųé]īzWēå–[Ō+^ņŠôŪ÷žˇ‡‚3kļōī?˙ķ?“7ßøÆ7Tųĩ¯}-m˛É&Ũwč'Ÿ|rúžĐsQ¸Ûüâŋ8Ų—ú͟öüūlžpûíˇ§G>ō‘ŅAĒīÚĶž–>öą%ŅčJļ6(E{ÉG<âYGŲčÔ×˙øĮįĩ6ŒŌdÎõķū‚ķ.ģė˛@§ūÕ¯~5]ļÉúzRķÉO~2Ŋá oHŸųĖgŌz뭗d(}Į;Ū‘Ū÷ž÷Ĩ?ūņ‰bŧęĒĢf5nRķ¨G=*}ųË_NË,ŗĖ@&‚‰(¯ĐkŦ‘L4ëĪcûØ~æč‚ī|ä#I\ŋŲÖ1 RķŗŸũ,­¸âŠŨ\´3HMŖé_î 1}5Ą_›T’sŅEĨÍ7ß<ũîwŋË'č°–_~ų~×]wMī~÷ģsŽ:É{í™}čCĨ÷ļÛnË]gžyfV~ˇÚjĢä`ŽÂßļŌD)Ēļ}T¤†˛Lü÷˙÷žĐEŅx9F%Šųá8TlP:K•Ô°> ÃŖ§Ē¯! Ã$5Mæ\?sŦāLF:HM?Č5üŽS%§P'žxâ‚'‹`ūŌ—ž” Î7ŋųÍlvßnģíŌ;ßųÎ,Đ/žøâ´ŅF% ũCōœŖį¨ŖŽĘ }‡vHûíˇ_úÃū^øÂĻM7Ũ4ŠË Nĩ0į;•P^ųĘWæēÄ|îsŸË§?EŪ`ƒ ’ ŅæÁBđŲĪ~69Ņ`]rRƚaãpŠÆ×Y†Õ˙øĮŲ9[rÉ%ķĻôŒg<#!qÚxėąĮ& ĪÆôā?8×sŨu×e,^ûÚ׿Ķ5ß)‹Fr˛ī|į;؊ŖkŽšfnž?īyĪK÷Ū{oîķĸ‹.šë°øœŌíĩ×^ŲÚÅŌĨŊ67Ũ:ëŦ“N;í´ÜvÃSžō”…°!ŒˇØb‹lSŋēW_}õlIÛc=ōß=ûļˇŊ-o–Ūaƒ…ŨjĢ­–?üđô„'ņ‰OäĶ&˙f;účŖĶwŪ™.ŊôԌĸgL˜ŧ=wÆgä:m{ũë_ŸąFvŗū=ëYĪj8ÛņõŲnęíhũdļ"HĖdŽÛ Z=)$įČ#L—]vY–įĘŲgŸ­2×_}>vØfOb™šōĘ+Ķ6Ûl“~ôŖåũįAzPÚpà ķsŦ4äŠŊŽmeļō¯ŠąīėŗĪ>?û*EtŲe—­Ũ§š3wĶ¤Ëøô§?Ž?ūø|€hcã-`Ÿ˛‡—‚Fß 7\xá…ųģô žëį×ŋūuŸ˙ųŸ˙ÉŲ ĩuˇŨvëē?ī{ß˟ßzë­ŲJ÷áø~–š:HˇŊņŌká/xAB é@UKyՏÖM÷)¸Đi”bQô˙Å_<÷Ąč,uu|āČDŨūNO4×éMÍeúBq—+–ē×ÍŨwß=˜ŗlŌāæ}ô ē#Ŋ‡Ã˛fĖ¤OœuÖY ékÚáĘÄ/ųË|˜llôíĪūs­~ÛSN9%ŋßŪcЧč~đ6ĒîŠÕ9W§?Ō™JANôíë_˙z&sú _ēŪÁœÉ¸C8čCZ0ž'tRž—uēlŅdũNĩĨÆāē&¨IU-Lå{Üã˛€Ļ€›Ā&eļ˜G‘•ãŽ;.ģŠ!„M!5‡rH>‘bŠDœ#ß+…đ§Œ[ˈÉmqÜpà éŖũč7“Éķ›mļY&SbÃMĖD°h„Éx⟘ "@áˇá8SŋΏΙØq‚â5¯yMیfLõšœHōrĮw¤õ×_?/šWŊęUYđĖ„Ģį-4EŦŧ[ÚTČ"Eß3ęA MbDŅAD¸ûŊį=īšŠŅÂĐbĶoXa§ÜĶā‚ˆX(Ąžū÷˙w&JKvjˇē7nˆ¨Ī´É&ėop)‹ĐWˇ÷.‰¯ŋÛ$EuœwŪyųĪ[ԓXš…Iė_Ú$Ļ ŖĐŪ6´•äP^(íŋ˙ū<[¯(-<ė“ĩėĨö=Bä´}€\¯*ÜäĩĖVūõ"5;í´S>¤Ŧ:ôôž#Ž8ĸvŸ†U7}ÁždŸĩĪøŽ˙ÛÛÔ_ˆHÁôÆoĖJ¤ƒ>ûšŊœ…;R1)ąÆ@ģJŠ;w5ã×m– „ōËŗ…ĸj?ėLãá ˇSGĐVûŨŪúÁ~0ë@Žč›š% t•Ôø/ŒŽÕM÷)¸pŨĸ étđ(n÷uučB~ÅWdƁļ:üļ÷ÃĄ”*ŠŅ7:!lévúÆĘ gúĨCDԚ Ü—Ãņnú„ņëÔ×čôcsEdëô›k¯Ŋ6ë¯ÚĢīžo,ĩkË-ˇĖcD*ĨĖ9¸ÕéÁĢÅwô™Xk­ĩ˛åÖ˙Í3‡Ítŗĸ_–ņ$7 ŠéÔe›Čˆ&ëwĒIM™€u‘\r§ō”ũĒû™/~Ē&×-'ųXˇ ĸœ{îšŲ…ë˜cŽÉ“C5˜Õâ§ŪmbÛLF“˛ŽÔ8čt?ŗ@°|“ŤA´•đSœŦ˜˜ÚkÂ9eābį}O~ō“ŗ@ę4gj§Åƒđ9áQÉ⒆ŧxŪ‚äĒĮ—¸ę~Vü&m‚Nņ5žüōËŗeƒ 0‰Ë&čY‹X;Ģ…Ĩ†ÃøŊĮƈXiåPß›ŠĶDã€ȄĖ:ØÂŪ˛Æp(ÂĮØĒĪÂRœš8TŸglŪ4R…ŦâE€tČāsžķœ|ŌäŊÁ4‰Ĩ‰P˜ÄūŖÍAbƁúôŧŗ $Į!(€ƒ"2NĄ´:0s¨g˙b äqIb'?ÉX ŠÔ͟ūô|˜f_×}Ô^ŗbļōĪŪĀĸ۝ʰÃ4 %’žĀÂUˇOû{7}ĄĒ`ÚXi( N÷;]Ō|fŸ4vHŠũ‘R+§ęg"5ûŗņsHéĶoũĩ÷u’{qŽ@/¨Û[yĩhŖš¤‹-ļØũHM§ûYÆs¤›îSÆŪnNšËôdnStXÖÕQ™GÜ úI,ŗŨÔ'ą¯ŗms˜Ų"ĪļœQôuĐī˜‹üŗG/ $‡;c¯ā­Ā:C‰sfŸąÕíĶNĖģé ÕđēöV„‰žbßņŽj.URã(ë ×6mŗ¯ûžĶt'ú‚ßx/ũƒžĐIjĒû3BEáÎM˙āuŅiŠéĻ#u{ĢC` z“}›ŪĪęũã~t0úI7Ũ§sŽđŽąˇ#|Š}ŋč,Ũꀟ6w-:Ā%č@'Š1.ˆEqq§gĐ3yä žŧ``Ëĩ‘ņw^&Ŋô‰N}­Šiq—‡c~cUõ×NKyĀ’UJuÎuĶ;ąu÷XĄ'Ō‡yĩ˜%ĐBÁ™Ž\tjzvšAKŗ˙_–É2cÁv&ßd%(ķ3B‹RīB?ÅÄ'€,0k'Ô ‹Ā$­÷Ž^ ĩœœŗ~ "úÖW]Û,j,Nx(ōNf|F@ÂOņījŅ“šyx6É*‹ë[õ˛)áė}ĄžˆÚÄÔĪĸߐÔęÖ~cŪ̘Č kLõی1t2eSŠļąW}mû|.›zÛú2¨ö‰’QĪ8’Ķ?ęs•e ;pĄĒJ,w=‡tÕ=°é>]­ĶŪEŲˇ¯ö*ô‡ƒöæÎģŊ< ėũ&ōĻĐf˛ŧuĶēĩĶ^Î J_ēé+ũę`sÁ´´outÖEqG` †¯õËoîYHM?úD7}­ß&úM¯94čĪûĪ~ßÛdũNũš~A‹ī͌@Ą0­8‰™Ö‘~A HN÷yō/ÖȨpāĖåŸį{Qˆ°Ŋg’EG…]ˇ÷4YŋAjÆ=Zņū@`4 #hÎH^$f$0ĮKZŠ@œ Ė|”-–ķĸYŧ`ä.bÕcĄ™Ô¨ŠmŦ&ë7HM[F-Ú &BaˆÍjÕAb† oT>áĖg’3ä߄OĪh~ Ё&ë7HMK'’û#r6÷TzuIŨJį›^ĪÍõs>Â|J‡uj1LĖæÚ÷q?ßD( Ŗ­%ö ë3H4‡S—ûvÂÎ÷s÷o6- OČą’ņÛ˙Ũ1č÷ÁlŪ9-ĪLÉé%_Æ-˙Ú4gÜ)™/ëŖS> zȚ’’bĐuG}˙@ Éú Rķ˙qĢF‚Äd’ŦŦDæšM}"‰î%Dr).ņ‰P"”Ū\JgøÅŲÖ%Bˆ¤ ÉÁJ}UL}O¤ áEFŠÃŦß÷M(>=åԟ‹nEQęŦC_D}“Ÿ`RJĄ0Œ>šDé¨č;%?RĶ÷‰iŠØũŋ_Œ¸Ü,z“R6eá_EūTqR¤FyąD×FŅ^Q†äŊ6Vær‡'"R mĨ&™äô’/ã–ũÂpŋ)čŽCEë_¤6‡"­Mbq_E0!QâĒEÔ6QÚDN%wȇjÎēšöÕE~uŠČæ]ĸ}‰šÖ­Ø÷ėpIŖ[4)Bl{}c>—&ë7HÍ˙Ÿ)ĸš‰ŦÕMAo:ĄDC)9Tš>ëûu z5IÕlę,Ī ’ÔΈŧՕ*ĻŪ)ė_É|<—öw{v.¤F$:$ÅøK5“ RĶ|ô„†˛XĶ(ģŊČM˜æ8÷zb”¤†B!gTɸŨĢmŗųŧ—qåâļž„¤uz/ü~”Ų!0I$§—|iĸÍ­ÉxJŪ9)(ü’5ړû&ÚļŠ*&ŧAÕ"7Ѝoä€hŗ"Ûō’>2%ŋ"ƒĘC(Qˇ"/D•ôËĻ…ŧÖöΨvMë™ôī7YŋOjX$N2øōÂH>Dq\~Ûמöĩtúé§įVN%â‰×]uí’-UæÛã?>mžųæ9Ļģ8ÜžīPūĶĻi‚aŨEX˛-šOXRXQ0wᐠŠAFȗ`ŌB”—F‹ÎŋYX|N8‹MŽH‰‘_?cØŗē~x—8ūbˋįŽŨrûø7áÄ2f|ĩURS˜I~&Įīd2)WI LN>ųäžÛw%žjci"†Õ~áÃm8ŠđŖÚ„ܘ3Ü!‚Ä ųÔۋÔ]N>ĢÖ Y&W—đĄæˇ<[ō9 +3$ę#+­7kĻ$h,Vš¸äô’—ĢSF ]/§‡5¯Ŋ÷Ū;˔:LN:ĩ$)ÜʍeÍ: õ˛K"o„zĸ ļ“œ™ä‹\!9WƒJģkŅį“N:)ë Ŧöl<&ėƒ”Đ:@|MNūĪģ#Ęf|Ȑņ2,ĶácdP~§2R¤B›ŗhdĄŒĄƒĖ–ķ᜘™ékm)C$ä™b0g™K}DZ‘Ld؁œÖĀĩk×:ÆdŖ>cdô™3šŽ9§]­×ģcĒÆ™ąyY˙W¯^íJaڈčQļÍK`ŠĨdØö͡ŲĶ Ø°Ų˛5l€ˆĻM]* đßΰatƒ^ŗItĸo#=‹eCΝ;×EH9ØÃ!Œ¨ŌĖôíéŸPÎ{ ÄÛú~oķ3sxO4@ŊĩĪX[Ska_ė+s$5ĩvō=bcG"褨&Ũå$ ˇ¨đ‰LŠį°‰ũ ¨†7\SĐAäÁ>ĘĀ6æ^Ä^ņ.Žexy*}pgdīeŦ!ŽBé¯Čũ†ą¤đĶīZ`˘đ,gŋæt„9:8C9Ša*NGc)Šđ_4{7ŦāÍãIiCjČ'œē°’ąrŒ"6šČL+ŧĪ”Íķ.}„ ŲTé˛0›Ÿ{úĀĻĒ–æųæ‰}”9Âąî]59lë[ÅŠéņlH ÁC<ŧ” ø¤ˆ€3P h¨Čč)ŠFŅąo¤%ZŸÔ؀);eØD ŒDØ,E\ŧĮ"İ…uĩŸ~úiqá…ÅåË—ģ¨†Íœ×ƒŌhBœDKé(…ļxONNēëJX’š2ũ )Cf"KJx¸ˆFšœN}#‹Ãĩŧ-ÆŖ9œ‹§€Ą¨ÉIäˆ'”ÁŅūøãN~(CéYĀ~˙ãÃRĻr\yû`áãB8Br 7›dĖ_ĢĖĖUmúÔč/¯•k€7ũŒô3F*žŠŅk‹ĩ1:Ô˙1bHĩaƒĐG„ē ęTŒC‹QØVŸKO*b[Fjü{ęžāmÉe—Ī OĻ͚ŪkņM ĪŠ˙G0Ø:ɁA¯é#¯+{ęzŋgsö¯ĄDL"i¨5É[yŠŠŲ`ƒíŗéŗŧĀ%Šá¸bį¤ˆíRžĮüŽŠGRΞ/ÖÉÜH ĮĮ OŋėZēDs8ÚĪEY‘‘NaÄÅ>ü駟.Ο?ß큁ŒČōā0āđ@~ŋ‡gØ íÆEUĸE†L` 6„=á õw:kßÖũ>č÷Á÷@2>āxLŲ^‚,„}ƒW8Ë<—c[Ķ/¤>‹F&?˙üsį´äLAŒ`7؃“•|4c„-EĻEzÜã@sŠē3”ŊŌ§ĀzņvP6ōÅéc‡ŨŒ‘mĩŽtlrĶwĪ7Zm.Ča.­ŋt¤Æf)Ô(BXō GzRÃĢ@9x* ĘÍ",sŊû¤†"ŲÜÃŗiņķ2X€4‚äq(?Ã/*¤†’`܈ĪˆÔ ^€ō›G}´S&l Ņx!ŒiˆÔ0  Ą1BúW’ ”\4ōŌ€‚“âŠÉÉy Bé(<%ä}áeáÉn.ÛRãŨȝK‹CÉ9æ¯Uf [mŧ"KlnHŠw‘2eøĨõY/ˆKIj[Ū$ž&`ŠËŠFjöšŠGÎģ ,Ȍ îŦ–$gķ[VĀ&ž7fD*ƒė#ĸ ôÂ&lm6á2uÃÆ+ĩ”Įž ëˇ 5NØŽŲČ>ŠqAHMÍ—öhc+Ø\ē­…C+€Ėæ%8Ÿ'NÄ”3ąĖž´€ĸc™az k Č bcpĀD0°Ã~įįĨK—:§-]‹īQ¤ųÔņŝ}Đ8†ŽYQˆ(°pßyįb[ĸÁ0 {ÁÉ:QąĮ39WAjVŋ° üĀˆ›ŒĪ‘Žlqڈœč—gØû9'Ëĸ%îC88Bŧ î’&ŊŽã–ÕŖ‡AčŦ#ŽLØIņšBDfôIŠlŖÁ\ä 7x,Ų4dĪŪqôr´û2Ņcd˙Ø[x˛6Ž™KkŅ߃&5&UˆĶĸŗ!‹~Ødcc Pla õ!#ŧްä[’hcHk)õ•+W:0Ī[ÄR`ÜO^ Đboj€cĀG‘7rđ4ˆnˆYŧR9ŠÍËĀDôI …˛đ)”¨ŽëabXÂKĸ¯ŧ)<̈Ea¨\|×H÷ÖääáÁ kÆĪx…y42  ><ŠÉt(RCĄ'y¸ĸ&!ƒ’”ú]‹ĖxgjãāM>‹Ôo䑤–qõĮú~/I P. ‘gyf,­ áãŠĩŖ°ž3ÎŌ”ęedfčũIr633<ōHD°]ķÁ €đ´ŠšŲ˜Ã~°SBlŸ›Ã@*ÄąŒÔ„Nõm$ģȆĐUö’`ĪÎ"5é\€n@ĻŒÔˆf‹ÂTŲÚ$pH$Ļ?˛eöeßö¯m&6sĩČ+í§ũ^zF蝴kFdŽsĸHė€}dyĀ5ĸa° ü#'0Š=“ր¤Ļą÷ŌĪ ¸Å˙ŗ=đûáųąîc‹D‹!Nj@^uÆZüŸŊVš›q(v Œ#”3‰sŋ( G‰>ŠŽ PRČ %öŅáÅaŲxŠ\ČÃ3C4 @dȐŨ´Ž"[FŸČn€5ŊרŖ:šņŗ[l)Ė íŒ å †#üŋ~,FVށ†ä°™•2ũ§´čīA“oSĩØx¤˙X,Āö5Ęy,,ÄāĄ`Ŗ¤Š)-Ë[œŧøÆFĖK^n÷YđåFęX`Öķ(=E ĸ¤3å"„/ gŅ‹ôđ40& Ø 0ĪA„mKâeŒžLá´•ĶĶĶ˙|”'ēÂ!z Đč ` Ĩ,>ę§l<ŒKMN”ĘķåtJŖ`úU–t6.’˛Ėq)S€QķŗlB߯,‘ū‰!51"C-25Ē#ŧ51—-ŅWßÄ ,˛uD´Dbbü°‚eīgKôņāpąŋshŗM,%ƒ#"2~ÂI0 §:lC"-ļœEžÂ2v< qbËGNUŽgX öņž!9ėk]íúŊ-ú{Ф& L[Ŧ˛EÅkS;´ųņû2ēŠÉa (ī˜3-Vũ-S•i ĀĀ }āęā\ž%p.Â#_øŦ’ƍ˜”$îŦqɉ$6 ÚyVD{–•–l•YË|“Ĩë¯˛1˛Č3cÃHSņ-B\'Båž)įđˇ…MéÄŽŸ“$§MâôŲēīë2Ŗ8‡¨kŋ*BdĶeZېd7ĩąĪ¤ŧÉlíĻmQë˜éúc"1­rŸƒũĢÉ^=Ąã)"ŌDą§ų=]ŊčW,‹g#*ˆ,üË~€A`ļD*+›qÖy,0˜w”úĐöéī¯ĩqąėÂtVãPFÎÂCø‘ĶĮžŪˇ1p‚ņ¯rx9œŋq€’'GBe<EWųRëZ?Æë[ô÷(HÍ1NbŽ)%°I ´…MžwŸĪJ’ŗOéįģ÷-9“˜žėįh˙jëΚ•ųPŋ0v­"1HlY1ĸ"'ŲV“€,Q'Q!™"ūî›ĨMžĢŗZĪĻuW‹ū&Š™ÖÜeoR[‘@‹QØJ&đĐ$9˜„ėÂÖ$$fX´i˙ū/Ņ‘‘‚Uš(ƒīDü”’ļ,ģb•wĖí™Ōķ¤ķû8ŖĪ˙]-ú›¤fn”ãĨZŒÂ\”$g.3}œãL3~^Ķū—U^™˜šZô7I͆gĪ÷!Ú*ų–îJ>.%p]-FaŽbK’3י?Œq'‰Y}žŌū­.ģŧķF øîH…ÅŒ¨ėne´čo’šÆyQ‰Bĩ åkM5Œ˛\rãã¯_îü“¨žļę3ōžÕ$āC@‡z)3{,­Å(˘×G’œu%˜÷¯#$1ëHīÆ{ĶūmN–ëĀEör•Ŋā-÷Õök%a,ÅÆāÜ%ááĻØĮcõ”¤fhėJÔĢøæXˆgžyĻÃΜ!dČŗ‚Ž4ŧ;ÁĨ "Jãŧ=g˨XæL ցĶā=įõ(ŅüņĮw\‹ Õ°ŒsīkČōKî9„eČÆ*­­4 [{kŅßŲ‘š8^9Bč0ŠŊĐūøÆot‡Û휋ĩņMˆ{jx§<ø;žÛĮ;_~ųeuoĩÖí˙đ=Ø~ 8ĖĻ⅁jû5 ¨÷Ŧwß}ˇ; QôBŠû„ŁX"ŧ%Ø= ųōåN\¨E!D%üņŽ&ŧÆH!ō:5ũĄúūöÛo_‹ō4P(ÆĘÉŧúå=ŪéĐ,„æĮė"ĩį•kˆÔ 1wß}÷âá‡î”ņĶG (Ģ&RĸBŊ CD|J}-sō1yđļ0žß}÷]'?ÆĮaW~ÆiæĸAæCCĖ„BDR~­Ãŧ æ€A‘Ŧ!RS;īLϟíČZņk’äņäŽZ’˜BÚŅ%- hG]ÚĘkė™@{āmrÜHßqŪöÛŪ‰ë8ikxGôĸļˇÚwĪ;ˇxõÕW;\ƒXqāFúŲķĪ?ßíĶĩũŪągË`‰@h' >Ą( 1ŠáüŦŨũŪ…˜!rpŌŌ'5œŌ˛H4ũ|衇:\U6$(2Pāŗ!R*&đÉ'ŸtDIÔ§†e<;°‚‚ÔëY˛AX‘RX ūŠ>leAMäĄ-ú;KRƒåFԁ°(4@¤6Ąؑ ¸$5WŽ\Y|öŲgŨī4ųĻ^ ō…3Ĩļ•B¤×ķGFxO(´ 5Rā0FŠ’õ ÄķE:×hįΟīŧ!ŌÄ‘5åERx(&20ôŧ˛ŋ}R#Ŋąbt4–%’ĸƋâ5úĸ?Ōß¤Û åžÆØyoDļNOO;ųRZ9CˆA¤SŪŠ1iŒ{Č2 ŖÂˆō˜“0ŧž´D¤Ļ6W䕤f"Öėˆē‘$įˆ&ŗ2”$1ĶßP4ŨQ,ī Ú5)í*H4.{qŲ`—!ŧ×yn k{Ģ}õ—_~ép°Ž„Ā0Ajd°ØÛkûĩŒ ™!Hĸ!BČ/#5OmėŠū”Ø€,¤ ÷IM‰ ĨÉÁ@pÎRÃÉĖᑚ ˛Rdöp(×° <Ō'5"DcdÃáNöIjnԋ$5Rc‘I¯ō p,⨊ā„;î¸Ŗ‹äP&J"Ø/#5 t)yĨR„2k‘š( ąab­=Ÿō0ÜŌÔ¤]‰´„DDEŸ}k"Z"Ėģ!z3†Ô nˆž÷#Rؐ"éwP‰„ƒb4ūüķĪÎë 5ŽW‘r]ÔüķĪ?]¤EŪ+˛ĸOŧ+Œ g’ģаoy\ĮØ {dfŽîšįž._—1ä9r­|V6Ī‘3‹Ä‡#`ˆĶ2Rèô=Bˡ”é^1—M}ē3đߞ%É9¤Ųúo_“ÄÎüÍÅūq*J˛úÉąÉņ CˆÔÔH §ëŪ‰æŦá:PÛ[E&8"ᕸđMÎ}÷Ũ×íņöíÚ~mĪ_…ÔHНÖᨌtvҏeRĀ22n ÎoiuC¤&ŌöúXæ…^č Ŧ!Í.ŌĪÆČ&IMŨūĖ’ÔŧáÉčGjDl€gų—H4&iU9ĪC|d/}Mh‰"&œÉ›AYīēëŽNyĩH+Ķš€~@)ˆĶ}‘œ27˛,é,ËũRāxĘį#8ÆÂ€DēW|P'¤Q`Q Ū÷ 'ģžßßx^Ų_ʏ´!KōCõYÎ(ō "ôÛoŋuD‹ˇG¨Õûy‡ ÷Á HÔYĨ9 c¤…i"Ī›nēŠû°Œ‘)Šīã‰:Is3'ĸlŧ4‹kŊ‘Á2oBښč‘ÜŪ 55Y*.ā˛ęģchsŲÔyŽ’äL{ö’ÄL{~ÎęŨœėėÁšČûĐŪˇ§Ho[dZ”M&ÄŪØŖÁ}ŧ3´ˇŠ@ØĮeKėŠ?GŖo˜Ep83kû5RÃI‹$Ā ¤Ë"5QŌyhėœÍp‚¨O üDËojJ\ˆ qžĨŸq|‹:!o0#ė#Ô'5˛S8TûXæöÛoŋŽ5ā$Ÿ- ™CXĻ” â e¤f摚ŗ øŠČņÅa‘øđß߅V–FŅå"šHųq~ËŗâZĄ\ėˇB_7Ų(ĸ€¤õAe5× Pd‡dŒi<:ŧ !ƒį^ßÅ užša ËF~ū¯<K%:„PŠ{KŲ gCį õûįģ'†ylŠĮ1ãÛį5sÚÔ÷)įMž;IÎ&ĨŲūŦ$1í2›ęsŗöj{Ą}cpLĢáūŊ5ŧwÔöVGö…Ū9ŖÉް¯ÂMCûõ˜ū]Ķ;ė‚$ųÖļāxˆČ:īq¯čĖrķÍ7zT Ë amČfT''xQ‹ūÎ.R3ÁųĘ.Ĩļ.Ŗ°õÎä V’@’œ•Ä6úĻ$1ŖEupĻũ;¸)Ûh‡ĨĪKŋG°DODBdšd; ´čo’šÃ˜ĶėeJ`- ´…ĩ^”7īLIrÖu’˜õäwHw§ũ;¤ŲÚ|_Ewō ŗ´2i\ŲG-ú;šÔøvÂwŲR)Ã“@ęīáÍYk“äœ-ą$1­+ęxŽOûw%0- Ŧ‚_šHá^ŧxqņīŋ˙.Ū{īŊi>{“H Ü dæÖ[o]ŧķÎ;)™”ĀÚØ6ÉIŗöå–H ņK.‘”ĀaH`UüŌLjˆãÍ7ß\üūûī‹ĶĶĶÅ-ˇÜrĘ^Ļf"!ÛgŸ}vņāƒ&Ą™Éœīc˜ë’œ$1û˜ĩ|gâ—\)éJ`]ü˛Š!Žoŋũļ;‘õå—_^Ü{īŊ‹ûīŋŋ;Ô([J %°{ ({¨dķĩk×}ôŅâ̝žĘ”ŗŨOÃŦ߸Œä$‰™õō˜ÔāŋLj:˛33—Ā&ņËʤ&æāũ÷ßīĀ”?ˇŨvÛÂÆ–-%؝žxâ‰Åß˙Ũ9xāÅ+¯ŧ˛ģ—į›R(INœm­ÆŸ\J`ßHü˛īČ÷Ī]›Æ/˙^—Ęv×ķzĄIENDŽB`‚django-axes-5.39.0/docs/index.rst0000644000175000017500000000054314277437635015605 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.39.0/manage.py0000644000175000017500000000037114277437635014615 0ustar jamesjames#!/usr/bin/env python import os import sys if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) django-axes-5.39.0/mypy.ini0000644000175000017500000000015114277437635014506 0ustar jamesjames[mypy] python_version = 3.6 ignore_missing_imports = True [mypy-axes.migrations.*] ignore_errors = True django-axes-5.39.0/pyproject.toml0000644000175000017500000000250014277437635015723 0ustar jamesjames[build-system] requires = ["setuptools>=30.3.0", "wheel", "setuptools_scm"] [tool.pytest.ini_options] testpaths = "tests" addopts = "--cov axes --cov-append --cov-branch --cov-report term-missing --cov-report=xml" DJANGO_SETTINGS_MODULE = "tests.settings" [tool.tox] legacy_tox_ini = """ [tox] envlist = py{37,38,39,310,py38}-dj32 py{38,39,310,py38}-dj40 py{38,39,310,py38}-dj41 py{38,39,310,py38}-djmain py310-djqa [gh-actions] python = 3.7: py37 3.8: py38 3.9: py39 3.10: py310 pypy-3.8: pypy38 [gh-actions:env] DJANGO = 3.2: dj32 4.0: dj40 4.1: dj41 main: djmain qa: djqa # Normal test environment runs pytest which orchestrates other tools [testenv] deps = -r requirements-test.txt dj32: django>=3.2,<3.3 dj40: django>=4.0,<4.1 dj41: django>=4.1,<4.2 djmain: https://github.com/django/django/archive/main.tar.gz usedevelop = true commands = pytest setenv = PYTHONDONTWRITEBYTECODE=1 # Django development version is allowed to fail the test matrix ignore_outcome = djmain: True pypy38-dj41: True ignore_errors = djmain: True pypy38-dj41: True # QA runs type checks, linting, and code formatting checks [testenv:py310-djqa] deps = -r requirements-qa.txt commands = mypy axes prospector black -t py38 --check --diff axes """ django-axes-5.39.0/requirements-qa.txt0000644000175000017500000000011514277437635016672 0ustar jamesjamesblack==22.6.0 mypy==0.971 prospector==1.7.7 types-pkg_resources # Type stub django-axes-5.39.0/requirements-test.txt0000644000175000017500000000014114277437635017247 0ustar jamesjames-e . coverage==6.4.4 pytest==7.1.2 pytest-cov==3.0.0 pytest-django==4.5.2 pytest-subtests==0.8.0 django-axes-5.39.0/requirements.txt0000644000175000017500000000013114277437635016271 0ustar jamesjames-e . -r requirements-qa.txt -r requirements-test.txt sphinx_rtd_theme==1.0.0 tox==3.25.1 django-axes-5.39.0/setup.py0000644000175000017500000000447114277437635014532 0ustar jamesjames#!/usr/bin/env python from setuptools import setup, find_packages setup( name="django-axes", 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"}, use_scm_version=True, setup_requires=["setuptools_scm"], python_requires=">=3.7", install_requires=["django>=3.2", "django-ipware>=3", "setuptools"], include_package_data=True, packages=find_packages(exclude=["tests"]), classifiers=[ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Environment :: Plugins", "Framework :: Django", "Framework :: Django :: 3.2", "Framework :: Django :: 4.0", "Framework :: Django :: 4.1", "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.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: Log Analysis", "Topic :: Security", "Topic :: System :: Logging", ], zip_safe=False, ) django-axes-5.39.0/tests/0000755000175000017500000000000014277437635014154 5ustar jamesjamesdjango-axes-5.39.0/tests/__init__.py0000644000175000017500000000000014277437635016253 0ustar jamesjamesdjango-axes-5.39.0/tests/base.py0000644000175000017500000001364014277437635015444 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.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, AccessLog, AccessFailureLog from axes.utils import reset 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.request.axes_failures_since_start = None self.request.axes_locked_out = False 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, } defaults.update(kwargs) return defaults def create_attempt(self, **kwargs): kwargs = self.get_kwargs_with_defaults(**kwargs) kwargs.setdefault("failures_since_start", 1) return AccessAttempt.objects.create(**kwargs) def create_log(self, **kwargs): return AccessLog.objects.create(**self.get_kwargs_with_defaults(**kwargs)) def create_failure_log(self, **kwargs): return AccessFailureLog.objects.create(**self.get_kwargs_with_defaults(**kwargs)) def reset(self, ip=None, username=None): return reset(ip, username) def login( self, is_valid_username=False, is_valid_password=False, remote_addr=None, **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=remote_addr or 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.39.0/tests/settings.py0000644000175000017500000000427714277437635016400 0ustar jamesjamesDATABASES = {"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.AxesStandaloneBackend", "django.contrib.auth.backends.ModelBackend", ] # Use MD5 for tests as it is considerably faster than other options # note that this should never be used in any online setting # where users actually log in to the system due to easy exploitability PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] ROOT_URLCONF = "tests.urls" 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_TZ = False LOGIN_REDIRECT_URL = "/admin/" AXES_FAILURE_LIMIT = 10 DEFAULT_AUTO_FIELD = "django.db.models.AutoField" django-axes-5.39.0/tests/test_admin.py0000644000175000017500000000225014277437635016654 0ustar jamesjamesfrom contextlib import suppress from importlib import reload from django.contrib import admin from django.test import override_settings import axes.admin from axes.models import AccessAttempt, AccessLog, AccessFailureLog from tests.base import AxesTestCase class AxesEnableAdminFlag(AxesTestCase): def setUp(self): with suppress(admin.sites.NotRegistered): admin.site.unregister(AccessAttempt) with suppress(admin.sites.NotRegistered): admin.site.unregister(AccessLog) with suppress(admin.sites.NotRegistered): admin.site.unregister(AccessFailureLog) @override_settings(AXES_ENABLE_ADMIN=False) def test_disable_admin(self): reload(axes.admin) self.assertFalse(admin.site.is_registered(AccessAttempt)) self.assertFalse(admin.site.is_registered(AccessLog)) self.assertFalse(admin.site.is_registered(AccessFailureLog)) def test_enable_admin_by_default(self): reload(axes.admin) self.assertTrue(admin.site.is_registered(AccessAttempt)) self.assertTrue(admin.site.is_registered(AccessLog)) self.assertTrue(admin.site.is_registered(AccessFailureLog)) django-axes-5.39.0/tests/test_attempts.py0000644000175000017500000001332614277437635017433 0ustar jamesjamesfrom unittest.mock import patch from django.http import HttpRequest from django.test import override_settings from django.utils.timezone import now from axes.attempts import get_cool_off_threshold from axes.models import AccessAttempt from axes.utils import reset, reset_request from tests.base import AxesTestCase 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 ResetResponseTestCase(AxesTestCase): USERNAME_1 = "foo_username" USERNAME_2 = "bar_username" IP_1 = "127.1.0.1" IP_2 = "127.1.0.2" def setUp(self): super().setUp() self.create_attempt() self.create_attempt(username=self.USERNAME_1, ip_address=self.IP_1) self.create_attempt(username=self.USERNAME_1, ip_address=self.IP_2) self.create_attempt(username=self.USERNAME_2, ip_address=self.IP_1) self.create_attempt(username=self.USERNAME_2, ip_address=self.IP_2) self.request = HttpRequest() def test_reset(self): reset_request(self.request) self.assertEqual(AccessAttempt.objects.count(), 5) def test_reset_ip(self): self.request.META["REMOTE_ADDR"] = self.IP_1 reset_request(self.request) self.assertEqual(AccessAttempt.objects.count(), 3) def test_reset_username(self): self.request.GET["username"] = self.USERNAME_1 reset_request(self.request) self.assertEqual(AccessAttempt.objects.count(), 5) def test_reset_ip_username(self): self.request.GET["username"] = self.USERNAME_1 self.request.META["REMOTE_ADDR"] = self.IP_1 reset_request(self.request) self.assertEqual(AccessAttempt.objects.count(), 3) @override_settings(AXES_ONLY_USER_FAILURES=True) def test_reset_user_failures(self): reset_request(self.request) self.assertEqual(AccessAttempt.objects.count(), 5) @override_settings(AXES_ONLY_USER_FAILURES=True) def test_reset_ip_user_failures(self): self.request.META["REMOTE_ADDR"] = self.IP_1 reset_request(self.request) self.assertEqual(AccessAttempt.objects.count(), 5) @override_settings(AXES_ONLY_USER_FAILURES=True) def test_reset_username_user_failures(self): self.request.GET["username"] = self.USERNAME_1 reset_request(self.request) self.assertEqual(AccessAttempt.objects.count(), 3) @override_settings(AXES_ONLY_USER_FAILURES=True) def test_reset_ip_username_user_failures(self): self.request.GET["username"] = self.USERNAME_1 self.request.META["REMOTE_ADDR"] = self.IP_1 reset_request(self.request) self.assertEqual(AccessAttempt.objects.count(), 3) @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True) def test_reset_user_or_ip(self): reset_request(self.request) self.assertEqual(AccessAttempt.objects.count(), 5) @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True) def test_reset_ip_user_or_ip(self): self.request.META["REMOTE_ADDR"] = self.IP_1 reset_request(self.request) self.assertEqual(AccessAttempt.objects.count(), 3) @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True) def test_reset_username_user_or_ip(self): self.request.GET["username"] = self.USERNAME_1 reset_request(self.request) self.assertEqual(AccessAttempt.objects.count(), 3) @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True) def test_reset_ip_username_user_or_ip(self): self.request.GET["username"] = self.USERNAME_1 self.request.META["REMOTE_ADDR"] = self.IP_1 reset_request(self.request) self.assertEqual(AccessAttempt.objects.count(), 2) @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) def test_reset_user_and_ip(self): reset_request(self.request) self.assertEqual(AccessAttempt.objects.count(), 5) @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) def test_reset_ip_user_and_ip(self): self.request.META["REMOTE_ADDR"] = self.IP_1 reset_request(self.request) self.assertEqual(AccessAttempt.objects.count(), 3) @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) def test_reset_username_user_and_ip(self): self.request.GET["username"] = self.USERNAME_1 reset_request(self.request) self.assertEqual(AccessAttempt.objects.count(), 3) @override_settings(AXES_LOCK_OUT_BY_USER_OR_AND=True) def test_reset_ip_username_user_and_ip(self): self.request.GET["username"] = self.USERNAME_1 self.request.META["REMOTE_ADDR"] = self.IP_1 reset_request(self.request) self.assertEqual(AccessAttempt.objects.count(), 3) django-axes-5.39.0/tests/test_backends.py0000644000175000017500000000135314277437635017341 0ustar jamesjamesfrom unittest.mock import patch, MagicMock from axes.backends import AxesBackend from axes.exceptions import ( AxesBackendRequestParameterRequired, AxesBackendPermissionDenied, ) from 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.39.0/tests/test_checks.py0000644000175000017500000000705514277437635017034 0ustar jamesjamesfrom django.core.checks import run_checks, Warning # pylint: disable=redefined-builtin from django.test import override_settings, modify_settings from axes.backends import AxesStandaloneBackend from axes.checks import Messages, Hints, Codes from 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 AxesSpecializedBackend(AxesStandaloneBackend): pass class BackendCheckTestCase(AxesTestCase): @modify_settings( AUTHENTICATION_BACKENDS={"remove": ["axes.backends.AxesStandaloneBackend"]} ) def test_backend_missing(self): warnings = run_checks() warning = Warning( msg=Messages.BACKEND_INVALID, hint=Hints.BACKEND_INVALID, id=Codes.BACKEND_INVALID, ) self.assertEqual(warnings, [warning]) @override_settings( AUTHENTICATION_BACKENDS=["tests.test_checks.AxesSpecializedBackend"] ) def test_specialized_backend(self): warnings = run_checks() self.assertEqual(warnings, []) @override_settings( AUTHENTICATION_BACKENDS=["tests.test_checks.AxesNotDefinedBackend"] ) def test_import_error(self): with self.assertRaises(ImportError): run_checks() @override_settings(AUTHENTICATION_BACKENDS=["module.not_defined"]) def test_module_not_found_error(self): with self.assertRaises(ModuleNotFoundError): run_checks() 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.39.0/tests/test_decorators.py0000644000175000017500000000362314277437635017736 0ustar jamesjamesfrom unittest.mock import MagicMock, patch from django.http import HttpResponse from axes.decorators import axes_dispatch, axes_form_invalid from 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.39.0/tests/test_failures.py0000644000175000017500000000137614277437635017406 0ustar jamesjamesfrom axes.models import AccessFailureLog from tests.base import AxesTestCase from axes.helpers import get_failure_limit from django.test import override_settings @override_settings(AXES_ENABLE_ACCESS_FAILURE_LOG=True) class FailureLogTestCase(AxesTestCase): def test_failure_log(self): self.login(is_valid_username=True, is_valid_password=False) self.assertEqual(AccessFailureLog.objects.count(), 1) self.assertTrue(AccessFailureLog.objects.filter(username=self.VALID_USERNAME).exists()) self.assertTrue(AccessFailureLog.objects.filter(ip_address=self.ip_address).exists()) def test_failure_locked_out(self): self.check_lockout() self.assertEqual(AccessFailureLog.objects.filter(locked_out=True).count(), 1) django-axes-5.39.0/tests/test_handlers.py0000644000175000017500000005112414277437635017370 0ustar jamesjamesfrom platform import python_implementation from unittest.mock import MagicMock, patch from pytest import mark from django.core.cache import cache from django.test import override_settings from django.urls import reverse from django.utils import timezone from django.utils.timezone import timedelta from axes.conf import settings from axes.handlers.proxy import AxesProxyHandler from axes.helpers import get_client_str from axes.models import AccessAttempt, AccessLog, AccessFailureLog from tests.base import AxesTestCase @override_settings(AXES_HANDLER="axes.handlers.base.AxesHandler") class AxesHandlerTestCase(AxesTestCase): @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)) @override_settings(AXES_ONLY_ADMIN_SITE=True) def test_only_admin_site(self): request = MagicMock() request.path = "/test/" self.assertTrue(AxesProxyHandler.is_allowed(self.request)) def test_is_admin_site(self): request = MagicMock() tests = ( # (AXES_ONLY_ADMIN_SITE, URL, Expected) (True, "/test/", True), (True, reverse("admin:index"), False), (False, "/test/", False), (False, reverse("admin:index"), False), ) for setting_value, url, expected in tests: with override_settings(AXES_ONLY_ADMIN_SITE=setting_value): request.path = url self.assertEqual(AxesProxyHandler().is_admin_site(request), expected) @override_settings(ROOT_URLCONF="tests.urls_empty") @override_settings(AXES_ONLY_ADMIN_SITE=True) def test_is_admin_site_no_admin_site(self): request = MagicMock() request.path = "/admin/" self.assertTrue(AxesProxyHandler().is_admin_site(self.request)) 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, self.request, ) 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") class ResetAttemptsTestCase(AxesHandlerBaseTestCase): """Resetting attempts is currently implemented only for database handler""" USERNAME_1 = "foo_username" USERNAME_2 = "bar_username" IP_1 = "127.1.0.1" IP_2 = "127.1.0.2" def setUp(self): super().setUp() self.create_attempt() self.create_attempt(username=self.USERNAME_1, ip_address=self.IP_1) self.create_attempt(username=self.USERNAME_1, ip_address=self.IP_2) self.create_attempt(username=self.USERNAME_2, ip_address=self.IP_1) self.create_attempt(username=self.USERNAME_2, ip_address=self.IP_2) def test_handler_reset_attempts(self): self.assertEqual(5, AxesProxyHandler.reset_attempts()) self.assertFalse(AccessAttempt.objects.count()) def test_handler_reset_attempts_username(self): self.assertEqual(2, AxesProxyHandler.reset_attempts(username=self.USERNAME_1)) self.assertEqual(AccessAttempt.objects.count(), 3) self.assertEqual( AccessAttempt.objects.filter(ip_address=self.USERNAME_1).count(), 0 ) def test_handler_reset_attempts_ip(self): self.assertEqual(2, AxesProxyHandler.reset_attempts(ip_address=self.IP_1)) self.assertEqual(AccessAttempt.objects.count(), 3) self.assertEqual(AccessAttempt.objects.filter(ip_address=self.IP_1).count(), 0) def test_handler_reset_attempts_ip_and_username(self): self.assertEqual( 1, AxesProxyHandler.reset_attempts( ip_address=self.IP_1, username=self.USERNAME_1 ), ) self.assertEqual(AccessAttempt.objects.count(), 4) self.assertEqual(AccessAttempt.objects.filter(ip_address=self.IP_1).count(), 1) self.create_attempt(username=self.USERNAME_1, ip_address=self.IP_1) self.assertEqual( 1, AxesProxyHandler.reset_attempts( ip_address=self.IP_1, username=self.USERNAME_2 ), ) self.assertEqual( 1, AxesProxyHandler.reset_attempts( ip_address=self.IP_2, username=self.USERNAME_1 ), ) def test_handler_reset_attempts_ip_or_username(self): self.assertEqual( 3, AxesProxyHandler.reset_attempts( ip_address=self.IP_1, username=self.USERNAME_1, ip_or_username=True ), ) self.assertEqual(AccessAttempt.objects.count(), 2) self.assertEqual(AccessAttempt.objects.filter(ip_address=self.IP_1).count(), 0) self.assertEqual( AccessAttempt.objects.filter(ip_address=self.USERNAME_1).count(), 0 ) @override_settings( AXES_HANDLER="axes.handlers.database.AxesDatabaseHandler", AXES_COOLOFF_TIME=timedelta(seconds=2), AXES_RESET_ON_SUCCESS=True, AXES_ENABLE_ACCESS_FAILURE_LOG=True, ) @mark.xfail( python_implementation() == "PyPy", reason="PyPy implementation is flaky for this test", strict=False, ) class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase): def test_handler_reset_attempts(self): self.create_attempt() self.assertEqual(1, AxesProxyHandler.reset_attempts()) self.assertFalse(AccessAttempt.objects.count()) def test_handler_reset_logs(self): self.create_log() self.assertEqual(1, AxesProxyHandler.reset_logs()) self.assertFalse(AccessLog.objects.count()) def test_handler_reset_logs_older_than_42_days(self): self.create_log() then = timezone.now() - timezone.timedelta(days=90) with patch("django.utils.timezone.now", return_value=then): self.create_log() self.assertEqual(AccessLog.objects.count(), 2) self.assertEqual(1, AxesProxyHandler.reset_logs(age_days=42)) self.assertEqual(AccessLog.objects.count(), 1) def test_handler_reset_failure_logs(self): self.create_failure_log() self.assertEqual(1, AxesProxyHandler.reset_failure_logs()) self.assertFalse(AccessFailureLog.objects.count()) def test_handler_reset_failure_logs_older_than_42_days(self): self.create_failure_log() then = timezone.now() - timezone.timedelta(days=90) with patch("django.utils.timezone.now", return_value=then): self.create_failure_log() self.assertEqual(AccessFailureLog.objects.count(), 2) self.assertEqual(1, AxesProxyHandler.reset_failure_logs(age_days=42)) self.assertEqual(AccessFailureLog.objects.count(), 1) def test_handler_remove_out_of_limit_failure_logs(self): _more = 10 for i in range(settings.AXES_ACCESS_FAILURE_LOG_PER_USER_LIMIT + _more): self.create_failure_log() self.assertEqual(_more, AxesProxyHandler.remove_out_of_limit_failure_logs(username=self.username)) @override_settings(AXES_RESET_ON_SUCCESS=True) def test_handler(self): self.check_handler() @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="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) @override_settings(AXES_ONLY_USER_FAILURES=True) @patch("axes.handlers.database.log") def test_user_login_failed_only_user_failures_with_none_username(self, log): credentials = {"username": None, "password": "test"} sender = MagicMock() AxesProxyHandler.user_login_failed(sender, credentials, self.request) attempt = AccessAttempt.objects.all() self.assertEqual(0, AccessAttempt.objects.count()) log.warning.assert_called_with( "AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created." ) def test_user_login_failed_with_none_username(self): credentials = {"username": None, "password": "test"} sender = MagicMock() AxesProxyHandler.user_login_failed(sender, credentials, self.request) attempt = AccessAttempt.objects.all() self.assertEqual(1, AccessAttempt.objects.filter(username__isnull=True).count()) def test_user_login_failed_multiple_username(self): configurations = ( (2, 1, {}, ["admin", "admin1"]), (2, 1, {"AXES_USE_USER_AGENT": True}, ["admin", "admin1"]), (2, 1, {"AXES_ONLY_USER_FAILURES": True}, ["admin", "admin1"]), ( 2, 1, {"AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP": True}, ["admin", "admin1"], ), ( 1, 2, {"AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP": True}, ["admin", "admin"], ), (1, 2, {"AXES_LOCK_OUT_BY_USER_OR_IP": True}, ["admin", "admin"]), (2, 1, {"AXES_LOCK_OUT_BY_USER_OR_IP": True}, ["admin", "admin1"]), ) for ( total_attempts_count, failures_since_start, overrides, usernames, ) in configurations: with self.settings(**overrides): with self.subTest( total_attempts_count=total_attempts_count, failures_since_start=failures_since_start, settings=overrides, ): self.login(username=usernames[0]) attempt = AccessAttempt.objects.get(username=usernames[0]) self.assertEqual(1, attempt.failures_since_start) # check the number of failures associated to the attempt self.login(username=usernames[1]) attempt = AccessAttempt.objects.get(username=usernames[1]) self.assertEqual(failures_since_start, attempt.failures_since_start) # check the number of distinct attempts self.assertEqual( total_attempts_count, AccessAttempt.objects.count() ) AccessAttempt.objects.all().delete() @override_settings(AXES_HANDLER="axes.handlers.cache.AxesCacheHandler") class ResetAttemptsCacheHandlerTestCase(AxesHandlerBaseTestCase): """Test reset attempts for the cache handler""" USERNAME_1 = "foo_username" USERNAME_2 = "bar_username" IP_1 = "127.1.0.1" IP_2 = "127.1.0.2" def set_up_login_attempts(self): """Set up the login attempts.""" self.login(username=self.USERNAME_1, remote_addr=self.IP_1) self.login(username=self.USERNAME_1, remote_addr=self.IP_2) self.login(username=self.USERNAME_2, remote_addr=self.IP_1) self.login(username=self.USERNAME_2, remote_addr=self.IP_2) def check_failures(self, failures, username=None, ip_address=None): if ip_address is None and username is None: raise NotImplementedError("Must supply ip_address or username") try: prev_ip = self.request.META["REMOTE_ADDR"] credentials = {"username": username} if username else {} if ip_address is not None: self.request.META["REMOTE_ADDR"] = ip_address self.assertEqual( failures, AxesProxyHandler.get_failures(self.request, credentials=credentials), ) finally: self.request.META["REMOTE_ADDR"] = prev_ip def test_handler_reset_attempts(self): with self.assertRaises(NotImplementedError): AxesProxyHandler.reset_attempts() @override_settings(AXES_ONLY_USER_FAILURES=True) def test_handler_reset_attempts_username(self): self.set_up_login_attempts() self.assertEqual( 2, AxesProxyHandler.get_failures( self.request, credentials={"username": self.USERNAME_1} ), ) self.assertEqual( 2, AxesProxyHandler.get_failures( self.request, credentials={"username": self.USERNAME_2} ), ) self.assertEqual(1, AxesProxyHandler.reset_attempts(username=self.USERNAME_1)) self.assertEqual( 0, AxesProxyHandler.get_failures( self.request, credentials={"username": self.USERNAME_1} ), ) self.assertEqual( 2, AxesProxyHandler.get_failures( self.request, credentials={"username": self.USERNAME_2} ), ) def test_handler_reset_attempts_ip(self): self.set_up_login_attempts() self.check_failures(2, ip_address=self.IP_1) self.assertEqual(1, AxesProxyHandler.reset_attempts(ip_address=self.IP_1)) self.check_failures(0, ip_address=self.IP_1) self.check_failures(2, ip_address=self.IP_2) @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) def test_handler_reset_attempts_ip_and_username(self): self.set_up_login_attempts() self.check_failures(1, username=self.USERNAME_1, ip_address=self.IP_1) self.check_failures(1, username=self.USERNAME_2, ip_address=self.IP_1) self.check_failures(1, username=self.USERNAME_1, ip_address=self.IP_2) self.assertEqual( 1, AxesProxyHandler.reset_attempts( ip_address=self.IP_1, username=self.USERNAME_1 ), ) self.check_failures(0, username=self.USERNAME_1, ip_address=self.IP_1) self.check_failures(1, username=self.USERNAME_2, ip_address=self.IP_1) self.check_failures(1, username=self.USERNAME_1, ip_address=self.IP_2) def test_handler_reset_attempts_ip_or_username(self): with self.assertRaises(NotImplementedError): AxesProxyHandler.reset_attempts() @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_ONLY_USER_FAILURES=True) @patch.object(cache, "set") @patch("axes.handlers.cache.log") def test_user_login_failed_only_user_failures_with_none_username( self, log, cache_set ): credentials = {"username": None, "password": "test"} sender = MagicMock() AxesProxyHandler.user_login_failed(sender, credentials, self.request) self.assertFalse(cache_set.called) log.warning.assert_called_with( "AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created." ) @patch.object(cache, "set") def test_user_login_failed_with_none_username(self, cache_set): credentials = {"username": None, "password": "test"} sender = MagicMock() AxesProxyHandler.user_login_failed(sender, credentials, self.request) self.assertTrue(cache_set.called) @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() def test_handler_is_allowed(self): self.assertEqual(True, AxesProxyHandler.is_allowed(self.request, {})) def test_handler_get_failures(self): self.assertEqual(0, AxesProxyHandler.get_failures(self.request, {})) @override_settings(AXES_HANDLER="axes.handlers.test.AxesTestHandler") class AxesTestHandlerTestCase(AxesHandlerBaseTestCase): def test_handler_reset_attempts(self): self.assertEqual(0, AxesProxyHandler.reset_attempts()) def test_handler_reset_logs(self): self.assertEqual(0, AxesProxyHandler.reset_logs()) def test_handler_is_allowed(self): self.assertEqual(True, AxesProxyHandler.is_allowed(self.request, {})) def test_handler_get_failures(self): self.assertEqual(0, AxesProxyHandler.get_failures(self.request, {})) django-axes-5.39.0/tests/test_helpers.py0000644000175000017500000007114714277437635017241 0ustar jamesjamesfrom datetime import timedelta from hashlib import sha256 from unittest.mock import patch from django.contrib.auth import get_user_model from django.http import JsonResponse, HttpResponseRedirect, HttpResponse, HttpRequest from django.test import override_settings, RequestFactory from axes.apps import AppConfig from axes.helpers import ( get_cache_timeout, get_client_str, get_client_username, get_client_cache_key, get_client_parameters, get_cool_off, get_cool_off_iso8601, get_lockout_response, is_client_ip_address_blacklisted, is_client_ip_address_whitelisted, is_client_method_whitelisted, is_ip_address_in_blacklist, is_ip_address_in_whitelist, is_user_attempt_whitelisted, toggleable, cleanse_parameters, ) from axes.models import AccessAttempt from tests.base import AxesTestCase @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.timedelta 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, self.request ) actual = get_client_str( username, ip_address, user_agent, path_info, self.request ) self.assertEqual(expected, actual) @override_settings(AXES_VERBOSE=True) def test_imbalanced_quotes(self): username = "butterfly.. },,," 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, self.request ) actual = get_client_str( username, ip_address, user_agent, path_info, self.request ) 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], self.request ) actual = get_client_str( username, ip_address, user_agent, path_info, self.request ) 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.request ) 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, self.request ) actual = get_client_str( username, ip_address, user_agent, path_info, self.request ) 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.request ) 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, self.request ) actual = get_client_str( username, ip_address, user_agent, path_info, self.request ) 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.request ) 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, self.request ) actual = get_client_str( username, ip_address, user_agent, path_info, self.request ) 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.request ) self.assertEqual(expected, actual) @override_settings( AXES_CLIENT_STR_CALLABLE="tests.test_helpers.get_dummy_client_str" ) def test_get_client_str_callable_return_str(self): self.assertEqual( get_client_str( "username", "ip_address", "user_agent", "path_info", self.request ), "client string", ) @override_settings( AXES_CLIENT_STR_CALLABLE="tests.test_helpers.get_dummy_client_str_using_request" ) def test_get_client_str_callable_using_request(self): self.request.user = self.user self.assertEqual( get_client_str( "username", "ip_address", "user_agent", "path_info", self.request ), self.email, ) def get_dummy_client_str(username, ip_address, user_agent, path_info, request): return "client string" def get_dummy_client_str_using_request( username, ip_address, user_agent, path_info, request ): return f"{request.user.email}" class ClientParametersTestCase(AxesTestCase): @override_settings(AXES_ONLY_USER_FAILURES=True) def test_get_filter_kwargs_user(self): self.assertEqual( 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( 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( 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_LOCK_OUT_BY_USER_OR_IP=True, AXES_USE_USER_AGENT=False, ) def test_get_filter_kwargs_user_or_ip(self): self.assertEqual( 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( 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( 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 = sha256(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 = sha256(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 = sha256(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) def test_default_get_client_username_drf(self): class DRFRequest: def __init__(self): self.data = {} self.POST = {} expected = "test-username" request = DRFRequest() request.data["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="tests.test_helpers.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.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" 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) def mock_get_cool_off_str(): return timedelta(seconds=30) class AxesCoolOffTestCase(AxesTestCase): @override_settings(AXES_COOLOFF_TIME=None) def test_get_cool_off_none(self): self.assertIsNone(get_cool_off()) @override_settings(AXES_COOLOFF_TIME=2) def test_get_cool_off_int(self): self.assertEqual(get_cool_off(), timedelta(hours=2)) @override_settings(AXES_COOLOFF_TIME=2.0) def test_get_cool_off_int(self): self.assertEqual(get_cool_off(), timedelta(minutes=120)) @override_settings(AXES_COOLOFF_TIME=0.25) def test_get_cool_off_int(self): self.assertEqual(get_cool_off(), timedelta(minutes=15)) @override_settings(AXES_COOLOFF_TIME=1.7) def test_get_cool_off_int(self): self.assertEqual(get_cool_off(), timedelta(seconds=6120)) @override_settings(AXES_COOLOFF_TIME=lambda: timedelta(seconds=30)) def test_get_cool_off_callable(self): self.assertEqual(get_cool_off(), timedelta(seconds=30)) @override_settings(AXES_COOLOFF_TIME="tests.test_helpers.mock_get_cool_off_str") def test_get_cool_off_path(self): self.assertEqual(get_cool_off(), timedelta(seconds=30)) def mock_is_whitelisted(request, credentials): return True class AxesWhitelistTestCase(AxesTestCase): def setUp(self): self.user_model = get_user_model() self.user = self.user_model.objects.create(username="jane.doe") self.request = HttpRequest() self.credentials = dict() def test_is_whitelisted(self): self.assertFalse(is_user_attempt_whitelisted(self.request, self.credentials)) @override_settings(AXES_WHITELIST_CALLABLE=mock_is_whitelisted) def test_is_whitelisted_override_callable(self): self.assertTrue(is_user_attempt_whitelisted(self.request, self.credentials)) @override_settings(AXES_WHITELIST_CALLABLE="tests.test_helpers.mock_is_whitelisted") def test_is_whitelisted_override_path(self): self.assertTrue(is_user_attempt_whitelisted(self.request, self.credentials)) @override_settings(AXES_WHITELIST_CALLABLE=42) def test_is_whitelisted_override_invalid(self): with self.assertRaises(TypeError): is_user_attempt_whitelisted(self.request, self.credentials) def mock_get_lockout_response(request, credentials): return HttpResponse(status=400) class AxesLockoutTestCase(AxesTestCase): def setUp(self): self.request = HttpRequest() self.credentials = dict() def test_get_lockout_response(self): response = get_lockout_response(self.request, self.credentials) self.assertEqual(403, response.status_code) @override_settings(AXES_HTTP_RESPONSE_CODE=429) def test_get_lockout_response_with_custom_http_response_code(self): response = get_lockout_response(self.request, self.credentials) self.assertEqual(429, response.status_code) @override_settings(AXES_LOCKOUT_CALLABLE=mock_get_lockout_response) def test_get_lockout_response_override_callable(self): response = get_lockout_response(self.request, self.credentials) self.assertEqual(400, response.status_code) @override_settings( AXES_LOCKOUT_CALLABLE="tests.test_helpers.mock_get_lockout_response" ) def test_get_lockout_response_override_path(self): response = get_lockout_response(self.request, self.credentials) self.assertEqual(400, response.status_code) @override_settings(AXES_LOCKOUT_CALLABLE=42) def test_get_lockout_response_override_invalid(self): with self.assertRaises(TypeError): get_lockout_response(self.request, self.credentials) class AxesCleanseParamsTestCase(AxesTestCase): def setUp(self): self.parameters = { "username": "test_user", "password": "test_password", "other_sensitive_data": "sensitive", } def test_cleanse_parameters(self): cleansed = cleanse_parameters(self.parameters) self.assertEqual("test_user", cleansed["username"]) self.assertEqual("********************", cleansed["password"]) self.assertEqual("sensitive", cleansed["other_sensitive_data"]) @override_settings(AXES_SENSITIVE_PARAMETERS=["other_sensitive_data"]) def test_cleanse_parameters_override_sensitive(self): cleansed = cleanse_parameters(self.parameters) self.assertEqual("test_user", cleansed["username"]) self.assertEqual("********************", cleansed["password"]) self.assertEqual("********************", cleansed["other_sensitive_data"]) @override_settings(AXES_SENSITIVE_PARAMETERS=["other_sensitive_data"]) @override_settings(AXES_PASSWORD_FORM_FIELD="username") def test_cleanse_parameters_override_both(self): cleansed = cleanse_parameters(self.parameters) self.assertEqual("********************", cleansed["username"]) self.assertEqual("********************", cleansed["password"]) self.assertEqual("********************", cleansed["other_sensitive_data"]) @override_settings(AXES_PASSWORD_FORM_FIELD=None) def test_cleanse_parameters_override_empty(self): cleansed = cleanse_parameters(self.parameters) self.assertEqual("test_user", cleansed["username"]) self.assertEqual("********************", cleansed["password"]) self.assertEqual("sensitive", cleansed["other_sensitive_data"]) django-axes-5.39.0/tests/test_logging.py0000644000175000017500000001070414277437635017215 0ustar jamesjamesfrom unittest.mock import patch from django.test import override_settings from django.urls import reverse from pkg_resources import get_distribution from axes.apps import AppConfig from axes.models import AccessAttempt, AccessLog from tests.base import AxesTestCase _BEGIN = "AXES: BEGIN version %s, %s" _VERSION = get_distribution("django-axes").version @patch("axes.apps.AppConfig.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(_BEGIN, _VERSION, "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(_BEGIN, _VERSION, "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(_BEGIN, _VERSION, "blocking by combination of username and IP") @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True) def test_axes_config_log_user_or_ip(self, log): AppConfig.initialize() log.info.assert_called_with(_BEGIN, _VERSION, "blocking by username or 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.39.0/tests/test_login.py0000644000175000017500000007054014277437635016703 0ustar jamesjames""" Integration tests for the login handling. TODO: Clean up the tests in this module. """ from datetime import timedelta from importlib import import_module from time import sleep from django.contrib.auth import get_user_model, login, logout from django.http import HttpRequest from django.test import override_settings, TestCase from django.urls import reverse from axes.conf import settings from axes.helpers import get_cache, make_cache_key_list, get_cool_off, get_failure_limit from axes.models import AccessAttempt from 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 DatabaseLoginTestCase(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" IP_3 = "10.2.2.3" USER_1 = "valid-user-1" USER_2 = "valid-user-2" USER_3 = "valid-user-3" 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 = '' ATTEMPT_NOT_BLOCKED = 200 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) def attempt_count(self): return AccessAttempt.objects.count() @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(self.attempt_count()) @override_settings(AXES_RESET_ON_SUCCESS=True) def test_reset_on_success_true(self): self.almost_lockout() self.assertTrue(self.attempt_count()) self.login(is_valid_username=True, is_valid_password=True) self.assertFalse(self.attempt_count()) response = self.lockout() self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED) self.assertTrue(self.attempt_count()) @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) 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, AXES_FAILURE_LIMIT=2 ) def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_using_cache_multiple_attempts( self, ): # User 1 is locked out from IP 1. response = self._login(self.USER_1, self.WRONG_PASSWORD, self.IP_1) self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED) # Second attempt from different IP response = self._login(self.USER_1, self.WRONG_PASSWORD, self.IP_2) self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED) # Second attempt from same IP, different username response = self._login(self.USER_2, self.WRONG_PASSWORD, self.IP_1) self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED) # User 1 is blocked from IP 1 response = self._login(self.USER_1, self.WRONG_PASSWORD, ip_addr=self.IP_1) self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED) # User 1 is blocked from IP 2 response = self._login(self.USER_1, self.WRONG_PASSWORD, ip_addr=self.IP_2) self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED) # User 2 can still login from IP 2, only he has 1 attempt left 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) # Test for true and false positives when blocking by user or IP together. # With cache enabled. When AXES_LOCK_OUT_BY_USER_OR_IP = True @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True) def test_lockout_by_user_or_ip_blocks_when_same_user_same_ip_using_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # 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_USER_OR_IP=True) def test_lockout_by_user_or_ip_allows_when_same_user_diff_ip_using_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # User 1 is blocked out from IP 1 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2) self.assertEqual(response.status_code, self.BLOCKED) @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True) def test_lockout_by_user_or_ip_allows_when_diff_user_same_ip_using_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # 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.BLOCKED) @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True, AXES_FAILURE_LIMIT=3) def test_lockout_by_user_or_ip_allows_when_diff_user_same_ip_using_cache_multiple_attempts( self, ): # User 1 is locked out from IP 1. response = self._login(self.USER_1, self.WRONG_PASSWORD, self.IP_1) self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED) # Second attempt from different IP response = self._login(self.USER_1, self.WRONG_PASSWORD, self.IP_2) self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED) # User 1 is blocked on all IPs, he reached 2 attempts response = self._login(self.USER_1, self.WRONG_PASSWORD, ip_addr=self.IP_2) self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED) response = self._login(self.USER_1, self.WRONG_PASSWORD, ip_addr=self.IP_3) self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED) # IP 1 has still one attempt left response = self._login(self.USER_2, self.WRONG_PASSWORD, self.IP_1) self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED) # But now IP 1 is blocked for all attempts response = self._login(self.USER_1, self.WRONG_PASSWORD, ip_addr=self.IP_1) self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED) response = self._login(self.USER_2, self.WRONG_PASSWORD, ip_addr=self.IP_1) self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED) response = self._login(self.USER_3, self.WRONG_PASSWORD, ip_addr=self.IP_1) self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED) @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True, AXES_FAILURE_LIMIT=3) def test_lockout_by_user_or_ip_allows_when_diff_user_same_ip_using_cache_multiple_failed_attempts( self, ): """Test, if the failed attempts make also impact on the attempt count""" # User 1 is locked out from IP 1. response = self._login(self.USER_1, self.WRONG_PASSWORD, self.IP_1) self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED) # Second attempt from different IP response = self._login(self.USER_1, self.WRONG_PASSWORD, self.IP_2) self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED) # Second attempt from same IP, different username response = self._login(self.USER_2, self.WRONG_PASSWORD, self.IP_1) self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED) # User 1 is blocked from IP 2 response = self._login(self.USER_1, self.WRONG_PASSWORD, ip_addr=self.IP_2) self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED) # On IP 2 it is only 2. attempt, for user 2 it is also 2. attempt -> allow log in response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2) self.assertEqual(response.status_code, self.ALLOWED) @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True) def test_lockout_by_user_or_ip_allows_when_diff_user_diff_ip_using_cache(self): # User 1 is locked out from IP 1. self._lockout_user1_from_ip1() # 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_USER_OR_IP=True) def test_lockout_by_user_or_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) @override_settings( AXES_COOLOFF_TIME=timedelta(seconds=1), AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT=False, AXES_FAILURE_LIMIT=2, ) def test_login_during_lockout_doesnt_reset_cool_off_time(self): # Lockout for _ in range(get_failure_limit(None, None)): self.login(self.USER_1) # Attempt during lockout sleep_time = get_cool_off().total_seconds() / 2 sleep(sleep_time) self.login(self.USER_1) sleep(sleep_time) # New attempt after initial lockout period: should work response = self.login(is_valid_username=True, is_valid_password=True) self.assertNotContains(response, self.LOCKED_MESSAGE, status_code=self.ALLOWED) @override_settings( AXES_COOLOFF_TIME=timedelta(seconds=1), AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT=True, AXES_FAILURE_LIMIT=2, ) def test_login_during_lockout_does_reset_cool_off_time(self): # Lockout for _ in range(get_failure_limit(None, None)): self.login(self.USER_1) # Attempt during lockout sleep_time = get_cool_off().total_seconds() / 2 sleep(sleep_time) self.login(self.USER_1) sleep(sleep_time) # New attempt after initial lockout period: should not work response = self.login(is_valid_username=True, is_valid_password=True) self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED) # Test the same logic with cache handler @override_settings(AXES_HANDLER="axes.handlers.cache.AxesCacheHandler") class CacheLoginTestCase(DatabaseLoginTestCase): def attempt_count(self): cache = get_cache() keys = cache._cache return len(keys) def reset(self, **kwargs): get_cache().delete(make_cache_key_list([kwargs])[0]) django-axes-5.39.0/tests/test_management.py0000644000175000017500000000726014277437635017706 0ustar jamesjamesfrom io import StringIO from unittest.mock import patch, Mock from django.core.management import call_command from django.utils import timezone from axes.models import AccessAttempt, AccessLog from tests.base import AxesTestCase class ResetAccessLogsManagementCommandTestCase(AxesTestCase): def setUp(self): self.msg_not_found = "No logs found.\n" self.msg_num_found = "{} logs removed.\n" days_3 = timezone.now() - timezone.timedelta(days=3) with patch("django.utils.timezone.now", Mock(return_value=days_3)): AccessLog.objects.create() days_13 = timezone.now() - timezone.timedelta(days=9) with patch("django.utils.timezone.now", Mock(return_value=days_13)): AccessLog.objects.create() days_30 = timezone.now() - timezone.timedelta(days=27) with patch("django.utils.timezone.now", Mock(return_value=days_30)): AccessLog.objects.create() def test_axes_delete_access_logs_default(self): out = StringIO() call_command("axes_reset_logs", stdout=out) self.assertEqual(self.msg_not_found, out.getvalue()) def test_axes_delete_access_logs_older_than_2_days(self): out = StringIO() call_command("axes_reset_logs", age=2, stdout=out) self.assertEqual(self.msg_num_found.format(3), out.getvalue()) def test_axes_delete_access_logs_older_than_4_days(self): out = StringIO() call_command("axes_reset_logs", age=4, stdout=out) self.assertEqual(self.msg_num_found.format(2), out.getvalue()) def test_axes_delete_access_logs_older_than_16_days(self): out = StringIO() call_command("axes_reset_logs", age=16, stdout=out) self.assertEqual(self.msg_num_found.format(1), out.getvalue()) 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.39.0/tests/test_middleware.py0000644000175000017500000000347014277437635017706 0ustar jamesjamesfrom django.conf import settings from django.http import HttpResponse, HttpRequest from django.test import override_settings from axes.middleware import AxesMiddleware from tests.base import AxesTestCase def get_username(request, credentials: dict) -> str: return credentials.get(settings.AXES_USERNAME_FORM_FIELD) 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) @override_settings(AXES_USERNAME_CALLABLE="tests.test_middleware.get_username") def test_lockout_response_with_axes_callable_username(self): def get_response(request): request.axes_locked_out = True request.axes_credentials = {settings.AXES_USERNAME_FORM_FIELD: 'username'} return HttpResponse() response = AxesMiddleware(get_response)(self.request) self.assertEqual(response.status_code, self.STATUS_LOCKOUT) @override_settings(AXES_ENABLED=False) def test_respects_enabled_switch(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_SUCCESS) django-axes-5.39.0/tests/test_models.py0000644000175000017500000000245114277437635017052 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, AccessFailureLog from 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() self.access_failure_log = AccessFailureLog() 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)) def test_access_failure_log_str(self): self.assertIn("Failed", str(self.access_failure_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.39.0/tests/test_signals.py0000644000175000017500000000075114277437635017230 0ustar jamesjamesfrom unittest.mock import MagicMock from axes.signals import user_locked_out from tests.base import AxesTestCase 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.39.0/tests/urls.py0000644000175000017500000000016014277437635015510 0ustar jamesjamesfrom django.contrib import admin from django.urls import path urlpatterns = [path("admin/", admin.site.urls)] django-axes-5.39.0/tests/urls_empty.py0000644000175000017500000000002714277437635016730 0ustar jamesjamesurlpatterns: list = []