diff -Nru django-axes-3.0.3/axes/admin.py django-axes-4.1.0/axes/admin.py --- django-axes-3.0.3/axes/admin.py 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/axes/admin.py 2018-02-18 11:59:07.000000000 +0000 @@ -16,7 +16,6 @@ list_filter = [ 'attempt_time', - 'username', 'path_info', ] @@ -73,7 +72,6 @@ list_filter = [ 'attempt_time', 'logout_time', - 'username', 'path_info', ] diff -Nru django-axes-3.0.3/axes/apps.py django-axes-4.1.0/axes/apps.py --- django-axes-3.0.3/axes/apps.py 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/axes/apps.py 2018-02-18 11:59:07.000000000 +0000 @@ -5,10 +5,20 @@ name = 'axes' def ready(self): + from django.conf import settings + from django.core.exceptions import ImproperlyConfigured + + if settings.CACHES[getattr(settings, 'AXES_CACHE', 'default')]['BACKEND'] == \ + 'django.core.cache.backends.locmem.LocMemCache': + raise ImproperlyConfigured( + 'django-axes does not work properly with LocMemCache as the default cache backend' + ' please add e.g. a DummyCache backend for axes and configure it with AXES_CACHE' + ) + from django.contrib.auth.views import LoginView from django.utils.decorators import method_decorator - from axes import signals # we must load signals + from axes import signals from axes.decorators import axes_dispatch from axes.decorators import axes_form_invalid diff -Nru django-axes-3.0.3/axes/attempts.py django-axes-4.1.0/axes/attempts.py --- django-axes-3.0.3/axes/attempts.py 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/axes/attempts.py 2018-02-18 11:59:07.000000000 +0000 @@ -2,12 +2,13 @@ from hashlib import md5 from django.contrib.auth import get_user_model -from django.core.cache import cache from django.utils import timezone +from ipware.ip import get_ip + from axes.conf import settings from axes.models import AccessAttempt -from axes.utils import get_ip +from axes.utils import get_axes_cache def _query_user_attempts(request): @@ -111,13 +112,13 @@ if attempt.trusted: attempt.failures_since_start = 0 attempt.save() - cache.set(cache_hash_key, 0, cache_timeout) + get_axes_cache().set(cache_hash_key, 0, cache_timeout) else: attempt.delete() force_reload = True - failures_cached = cache.get(cache_hash_key) + failures_cached = get_axes_cache().get(cache_hash_key) if failures_cached is not None: - cache.set( + get_axes_cache().set( cache_hash_key, failures_cached - 1, cache_timeout ) @@ -196,7 +197,7 @@ return False cache_hash_key = get_cache_key(request) - failures_cached = cache.get(cache_hash_key) + failures_cached = get_axes_cache().get(cache_hash_key) if failures_cached is not None: return ( failures_cached >= settings.AXES_FAILURE_LIMIT and diff -Nru django-axes-3.0.3/axes/conf.py django-axes-4.1.0/axes/conf.py --- django-axes-3.0.3/axes/conf.py 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/axes/conf.py 2018-02-18 11:59:07.000000000 +0000 @@ -1,17 +1,9 @@ from django.conf import settings + from appconf import AppConf class MyAppConf(AppConf): - # see if the django app is sitting behind a reverse proxy - BEHIND_REVERSE_PROXY = False - - # if we are behind a proxy, we need to know how many proxies there are - NUM_PROXIES = 0 - - # behind a reverse proxy, look for the ip address using this value - REVERSE_PROXY_HEADER = 'HTTP_X_FORWARDED_FOR' - # see if the user has overridden the failure limit FAILURE_LIMIT = 3 diff -Nru django-axes-3.0.3/axes/decorators.py django-axes-4.1.0/axes/decorators.py --- django-axes-3.0.3/axes/decorators.py 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/axes/decorators.py 2018-02-18 11:59:07.000000000 +0000 @@ -25,19 +25,6 @@ log.info('AXES: blocking by IP only.') -if settings.AXES_BEHIND_REVERSE_PROXY: - log.debug('AXES: Axes is configured to be behind reverse proxy') - log.debug( - 'AXES: Looking for header value %s', settings.AXES_REVERSE_PROXY_HEADER - ) - log.debug( - 'AXES: Number of proxies configured: {} ' - '(please check this if you are using a custom header)'.format( - settings.AXES_NUM_PROXIES - ) - ) - - def axes_dispatch(func): def inner(request, *args, **kwargs): if is_already_locked(request): diff -Nru django-axes-3.0.3/axes/__init__.py django-axes-4.1.0/axes/__init__.py --- django-axes-3.0.3/axes/__init__.py 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/axes/__init__.py 2018-02-18 11:59:07.000000000 +0000 @@ -1,4 +1,4 @@ -__version__ = '3.0.3' +__version__ = '4.1.0' default_app_config = 'axes.apps.AppConfig' diff -Nru django-axes-3.0.3/axes/signals.py django-axes-4.1.0/axes/signals.py --- django-axes-3.0.3/axes/signals.py 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/axes/signals.py 2018-02-18 11:59:07.000000000 +0000 @@ -3,12 +3,13 @@ from django.contrib.auth.signals import user_logged_in from django.contrib.auth.signals import user_logged_out from django.contrib.auth.signals import user_login_failed -from django.core.cache import cache from django.db.models.signals import post_save, post_delete from django.dispatch import receiver from django.dispatch import Signal from django.utils import timezone +from ipware.ip import get_ip + from axes.conf import settings from axes.attempts import get_cache_key from axes.attempts import get_cache_timeout @@ -17,8 +18,8 @@ from axes.attempts import ip_in_whitelist from axes.models import AccessLog, AccessAttempt from axes.utils import get_client_str -from axes.utils import get_ip from axes.utils import query2str +from axes.utils import get_axes_cache log = logging.getLogger(settings.AXES_LOGGER) @@ -31,8 +32,12 @@ def log_user_login_failed(sender, credentials, request, **kwargs): """ Create an AccessAttempt record if the login wasn't successful """ + if request is None or settings.AXES_USERNAME_FORM_FIELD not in credentials: + log.error('Attempt to authenticate with a custom backend failed.') + return + ip_address = get_ip(request) - username = credentials['username'] + username = credentials[settings.AXES_USERNAME_FORM_FIELD] user_agent = request.META.get('HTTP_USER_AGENT', '')[:255] path_info = request.META.get('PATH_INFO', '')[:255] http_accept = request.META.get('HTTP_ACCEPT', '')[:1025] @@ -45,7 +50,7 @@ cache_hash_key = get_cache_key(request) cache_timeout = get_cache_timeout() - failures_cached = cache.get(cache_hash_key) + failures_cached = get_axes_cache().get(cache_hash_key) if failures_cached is not None: failures = failures_cached else: @@ -54,7 +59,7 @@ # add a failed attempt for this user failures += 1 - cache.set(cache_hash_key, failures, cache_timeout) + get_axes_cache().set(cache_hash_key, failures, cache_timeout) # has already attempted, update the info if len(attempts): @@ -158,12 +163,12 @@ @receiver(post_save, sender=AccessAttempt) def update_cache_after_save(instance, **kwargs): cache_hash_key = get_cache_key(instance) - if not cache.get(cache_hash_key): + if not get_axes_cache().get(cache_hash_key): cache_timeout = get_cache_timeout() - cache.set(cache_hash_key, instance.failures_since_start, cache_timeout) + get_axes_cache().set(cache_hash_key, instance.failures_since_start, cache_timeout) @receiver(post_delete, sender=AccessAttempt) def delete_cache_after_delete(instance, **kwargs): cache_hash_key = get_cache_key(instance) - cache.delete(cache_hash_key) + get_axes_cache().delete(cache_hash_key) diff -Nru django-axes-3.0.3/axes/tests/test_access_attempt.py django-axes-4.1.0/axes/tests/test_access_attempt.py --- django-axes-3.0.3/axes/tests/test_access_attempt.py 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/axes/tests/test_access_attempt.py 2018-02-18 11:59:07.000000000 +0000 @@ -7,6 +7,7 @@ from django.test import TestCase, override_settings from django.urls import reverse +from django.contrib.auth import authenticate from django.contrib.auth.models import User from django.test.client import RequestFactory @@ -187,7 +188,7 @@ # Make a login attempt again self.test_valid_login() - @patch('axes.utils.get_ip', return_value='127.0.0.1') + @patch('ipware.ip.get_ip', return_value='127.0.0.1') def test_get_cache_key(self, get_ip_mock): """ Test the cache key format""" # Getting cache key from request @@ -379,3 +380,12 @@ response = self.client.get(reverse('admin:index')) self.assertEqual(response.status_code, 200) + + def test_custom_authentication_backend(self): + ''' + ``log_user_login_failed`` should shortcircuit if an attempt to authenticate + with a custom authentication backend fails. + ''' + authenticate(foo='bar') + + self.assertEqual(AccessLog.objects.all().count(), 0) diff -Nru django-axes-3.0.3/axes/tests/test_proxy.py django-axes-4.1.0/axes/tests/test_proxy.py --- django-axes-3.0.3/axes/tests/test_proxy.py 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/axes/tests/test_proxy.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,109 +0,0 @@ -from django.test import TestCase, override_settings - -from axes.conf import settings -from axes.utils import get_ip - - -class MockRequest: - def __init__(self): - self.META = dict() - - -@override_settings(AXES_BEHIND_REVERSE_PROXY=True) -class GetIPProxyTest(TestCase): - """Test get_ip returns correct addresses with proxy - """ - def setUp(self): - self.request = MockRequest() - - def test_iis_ipv4_port_stripping(self): - self.ip = '192.168.1.1' - - valid_headers = [ - '192.168.1.1:6112', - '192.168.1.1:6033, 192.168.1.2:9001', - ] - - for header in valid_headers: - self.request.META['HTTP_X_FORWARDED_FOR'] = header - self.assertEqual(self.ip, get_ip(self.request)) - - def test_valid_ipv4_parsing(self): - self.ip = '192.168.1.1' - - valid_headers = [ - '192.168.1.1', - '192.168.1.1, 192.168.1.2', - ' 192.168.1.1 , 192.168.1.2 ', - ' 192.168.1.1 , 2001:db8:cafe::17 ', - ] - - for header in valid_headers: - self.request.META['HTTP_X_FORWARDED_FOR'] = header - self.assertEqual(self.ip, get_ip(self.request)) - - def test_valid_ipv6_parsing(self): - self.ip = '2001:db8:cafe::17' - - valid_headers = [ - '2001:db8:cafe::17', - '2001:db8:cafe::17 , 2001:db8:cafe::18', - '2001:db8:cafe::17, 2001:db8:cafe::18, 192.168.1.1', - ] - - for header in valid_headers: - self.request.META['HTTP_X_FORWARDED_FOR'] = header - self.assertEqual(self.ip, get_ip(self.request)) - - -@override_settings(AXES_BEHIND_REVERSE_PROXY=True) -@override_settings(AXES_REVERSE_PROXY_HEADER='HTTP_X_FORWARDED_FOR') -@override_settings(AXES_NUM_PROXIES=2) -class GetIPNumProxiesTest(TestCase): - """Test that get_ip returns the correct last IP when NUM_PROXIES is configured - """ - def setUp(self): - self.request = MockRequest() - - def test_header_ordering(self): - self.ip = '2.2.2.2' - - valid_headers = [ - '4.4.4.4, 3.3.3.3, 2.2.2.2, 1.1.1.1', - ' 3.3.3.3, 2.2.2.2, 1.1.1.1', - ' 2.2.2.2, 1.1.1.1', - ] - - for header in valid_headers: - self.request.META[settings.AXES_REVERSE_PROXY_HEADER] = header - self.assertEqual(self.ip, get_ip(self.request)) - - def test_invalid_headers_too_few(self): - self.request.META[settings.AXES_REVERSE_PROXY_HEADER] = '1.1.1.1' - with self.assertRaises(Warning): - get_ip(self.request) - - def test_invalid_headers_no_ip(self): - self.request.META[settings.AXES_REVERSE_PROXY_HEADER] = '' - with self.assertRaises(Warning): - get_ip(self.request) - - -@override_settings(AXES_BEHIND_REVERSE_PROXY=True) -@override_settings(AXES_REVERSE_PROXY_HEADER='HTTP_X_AXES_CUSTOM_HEADER') -class GetIPProxyCustomHeaderTest(TestCase): - """Test that get_ip returns correct addresses with a custom proxy header - """ - def setUp(self): - self.request = MockRequest() - - def test_custom_header_parsing(self): - self.ip = '2001:db8:cafe::17' - - valid_headers = [ - ' 2001:db8:cafe::17 , 2001:db8:cafe::18', - ] - - for header in valid_headers: - self.request.META[settings.AXES_REVERSE_PROXY_HEADER] = header - self.assertEqual(self.ip, get_ip(self.request)) diff -Nru django-axes-3.0.3/axes/test_settings_cache.py django-axes-4.1.0/axes/test_settings_cache.py --- django-axes-3.0.3/axes/test_settings_cache.py 1970-01-01 00:00:00.000000000 +0000 +++ django-axes-4.1.0/axes/test_settings_cache.py 2018-02-18 11:59:07.000000000 +0000 @@ -0,0 +1,9 @@ +from .test_settings import * + +AXES_CACHE = 'axes' + +CACHES = { + 'axes': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache' + } +} diff -Nru django-axes-3.0.3/axes/utils.py django-axes-4.1.0/axes/utils.py --- django-axes-3.0.3/axes/utils.py 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/axes/utils.py 2018-02-18 11:59:07.000000000 +0000 @@ -1,12 +1,20 @@ +from platform import python_version +from sys import platform +if python_version() < '3.4' and platform == 'win32': + import win_inet_pton from socket import inet_pton, AF_INET6, error -from django.core.cache import cache +from django.core.cache import cache, caches from django.utils import six from axes.conf import settings from axes.models import AccessAttempt +def get_axes_cache(): + return caches[getattr(settings, 'AXES_CACHE', 'default')] + + def query2str(items, max_length=1024): """Turns a dictionary into an easy-to-read list of key-value pairs. @@ -49,80 +57,10 @@ return True -def get_ip(request): - """Parse IP address from REMOTE_ADDR or - AXES_REVERSE_PROXY_HEADER if AXES_BEHIND_REVERSE_PROXY is set.""" - if settings.AXES_BEHIND_REVERSE_PROXY: - # For requests originating from behind a reverse proxy, - # resolve the IP address from the given AXES_REVERSE_PROXY_HEADER. - # AXES_REVERSE_PROXY_HEADER defaults to HTTP_X_FORWARDED_FOR, - # which is the Django name for the HTTP X-Forwarder-For header. - # Please see RFC7239 for additional information: - # https://tools.ietf.org/html/rfc7239#section-5 - - # The REVERSE_PROXY_HEADER HTTP header is a list - # of potentionally unsecure IPs, for example: - # X-Forwarded-For: 1.1.1.1, 11.11.11.11:8080, 111.111.111.111 - ip_str = request.META.get(settings.AXES_REVERSE_PROXY_HEADER, '') - - # We need to know the number of proxies present in the request chain - # in order to securely calculate the one IP that is the real client IP. - # - # This is because IP headers can have multiple IPs in different - # configurations, with e.g. the X-Forwarded-For header containing - # the originating client IP, proxies and possibly spoofed values. - # - # If you are using a special header for client calculation such as the - # X-Real-IP or the like with nginx, please check this configuration. - # - # Please see discussion for more information: - # https://github.com/jazzband/django-axes/issues/224 - ip_list = [ip.strip() for ip in ip_str.split(',')] - - # Pick the nth last IP in the given list of addresses after parsing - if len(ip_list) >= settings.AXES_NUM_PROXIES: - ip = ip_list[-settings.AXES_NUM_PROXIES] - - # Fix IIS adding client port number to the - # 'X-Forwarded-For' header (strip port) - if not is_ipv6(ip): - ip = ip.split(':', 1)[0] - - # If nth last is not found, default to no IP and raise a warning - else: - ip = '' - raise Warning( - 'AXES: Axes is configured for operation behind a ' - 'reverse proxy but received too few IPs in the HTTP ' - 'AXES_REVERSE_PROXY_HEADER. Check your ' - 'AXES_NUM_PROXIES configuration. ' - 'Header name: {0}, value: {1}'.format( - settings.AXES_REVERSE_PROXY_HEADER, ip_str - ) - ) - - if not ip: - raise Warning( - 'AXES: Axes is configured for operation behind a reverse ' - 'proxy but could not find a suitable IP in the specified ' - 'HTTP header. Check your proxy server settings to make ' - 'sure correct headers are being passed to Django in ' - 'AXES_REVERSE_PROXY_HEADER. ' - 'Header name: {0}, value: {1}'.format( - settings.AXES_REVERSE_PROXY_HEADER, ip_str - ) - ) - - return ip - - return request.META.get('REMOTE_ADDR', '') - - def reset(ip=None, username=None): """Reset records that match ip or username, and return the count of removed attempts. """ - count = 0 attempts = AccessAttempt.objects.all() if ip: @@ -130,16 +68,8 @@ if username: attempts = attempts.filter(username=username) - if attempts: - count = attempts.count() - # import should be here to avoid circular dependency with get_ip - from axes.attempts import get_cache_key - for attempt in attempts: - cache_hash_key = get_cache_key(attempt) - if cache.get(cache_hash_key): - cache.delete(cache_hash_key) + count, _ = attempts.delete() - attempts.delete() return count diff -Nru django-axes-3.0.3/CHANGES.txt django-axes-4.1.0/CHANGES.txt --- django-axes-3.0.3/CHANGES.txt 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/CHANGES.txt 2018-02-18 11:59:07.000000000 +0000 @@ -1,6 +1,62 @@ Changes ======= +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) ------------------ diff -Nru django-axes-3.0.3/debian/changelog django-axes-4.1.0/debian/changelog --- django-axes-3.0.3/debian/changelog 2017-11-25 19:50:33.000000000 +0000 +++ django-axes-4.1.0/debian/changelog 2018-02-24 19:37:20.000000000 +0000 @@ -1,3 +1,11 @@ +django-axes (4.1.0-1) unstable; urgency=low + + * New upstream version 4.1.0. + * Add build dependency on python3-django-ipware. + * Bump standards version to 4.1.3. + + -- James Valleroy Sat, 24 Feb 2018 20:37:20 +0100 + django-axes (3.0.3-1) unstable; urgency=medium * New upstream version 3.0.3. (Closes: #880833) diff -Nru django-axes-3.0.3/debian/control django-axes-4.1.0/debian/control --- django-axes-3.0.3/debian/control 2017-11-25 19:50:33.000000000 +0000 +++ django-axes-4.1.0/debian/control 2018-02-24 19:37:20.000000000 +0000 @@ -13,11 +13,12 @@ python3-all, python3-django, python3-django-appconf, + python3-django-ipware, python3-mock, python3-setuptools, python3-sphinx, python3-sphinx-rtd-theme, -Standards-Version: 4.1.1 +Standards-Version: 4.1.3 Homepage: https://github.com/jazzband/django-axes Vcs-Git: https://anonscm.debian.org/git/freedombox/django-axes.git Vcs-Browser: https://anonscm.debian.org/cgit/freedombox/django-axes.git diff -Nru django-axes-3.0.3/docs/configuration.rst django-axes-4.1.0/docs/configuration.rst --- django-axes-3.0.3/docs/configuration.rst 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/docs/configuration.rst 2018-02-18 11:59:07.000000000 +0000 @@ -18,6 +18,42 @@ Remember to run ``python manage.py migrate`` to sync the database. +Known configuration problems +---------------------------- + +If you are running Axes on a deployment with in-memory Django cache, +the ``axes_reset`` functionality might not work predictably. + +Axes caches access attempts application-wide, and the in-memory cache +only caches access attempts per Django process, so for example +resets made in one web server process or the command line with ``axes_reset`` +might not remove lock-outs that are in the sepate process' in-memory cache +such as the web server process serving your login or admin page. + +To circumvent this problem please use somethings else than +``django.core.cache.backends.locmem.LocMemCache`` as your +cache backend in Django cache ``BACKEND`` setting. + +If it is not an option to change the default cache 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 = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + }, + 'axes_cache': { + 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', + } + } + +The next step is to tell axes to use this cache through adding ``AXES_CACHE`` +to your ``settings.py`` file:: + + AXES_CACHE = 'axes_cache' + +There are no known problems in other cache backends such as +``DummyCache``, ``FileBasedCache``, or ``MemcachedCache`` backends. Customizing Axes ---------------- @@ -25,6 +61,8 @@ You have a couple options available to you to customize ``django-axes`` a bit. These should be defined in your ``settings.py`` file. +* ``AXES_CACHE``: The name of the cache for axes to use. + Default: ``'default'`` * ``AXES_FAILURE_LIMIT``: The number of login attempts allowed before a record is created for the failed logins. Default: ``3`` * ``AXES_LOCK_OUT_AT_FAILURE``: After the number of allowed login attempts @@ -49,6 +87,8 @@ Default: ``True`` * ``AXES_USERNAME_FORM_FIELD``: the name of the form field that contains your users usernames. Default: ``username`` +* ``AXES_PASSWORD_FORM_FIELD``: the name of the form field that contains your + users password. Default: ``password`` * ``AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP``: If ``True`` prevents the login from IP under a particular user if the attempt limit has been exceeded, otherwise lock out based on IP. @@ -59,10 +99,6 @@ * ``AXES_NEVER_LOCKOUT_WHITELIST``: If ``True``, users can always login from whitelisted IP addresses. Default: ``False`` * ``AXES_IP_WHITELIST``: A list of IP's to be whitelisted. For example: AXES_IP_WHITELIST=['0.0.0.0']. Default: [] -* ``AXES_BEHIND_REVERSE_PROXY``: If ``True``, it will look for the IP address from the header defined at ``AXES_REVERSE_PROXY_HEADER``. Please make sure if you enable this setting to configure your proxy to set the correct value for the header, otherwise you could be attacked by setting this header directly in every request. Default: ``False`` -* ``AXES_REVERSE_PROXY_HEADER``: If ``AXES_BEHIND_REVERSE_PROXY`` is ``True``, it will look for the IP address from this header. - Default: ``HTTP_X_FORWARDED_FOR`` -* ``AXES_NUM_PROXIES``: If ``AXES_BEHIND_REVERSE_PROXY`` is ``True``, use this value to calculate the end user IP address from the end of the list of IPs in header ``AXES_REVERSE_PROXY_HEADER``. For example, if you have one (1) proxy configured and set ``AXES_NUM_PROXIES = 1`` we, choose IP ``[ip.strip() for ip in request.META.get(AXES_REVERSE_PROXY_HEADER).split(',')][-1]``. For ``X-Forwarded-For: a, b, client-ip`` this would pick the value ``client-ip``. This configuration is used to prevent ``X-Forwarded-For`` (XFF) header spoofing or injection by the end user, because the ``X-Forwarded-For`` headers can be added to the request by the end user, circumventing the IP locking mechanisms in Axes. If you are running with Apache, nginx, or Elastic Load Balancer, you should set this to ``1``. It is by default configured to ``0`` for backwards compatibility. Default: ``0`` -* ``AXES_DISABLE_ACCESS_LOG``: If ``True``, disable all access logging, so the admin interface will be empty. -* ``AXES_DISABLE_SUCCESS_ACCESS_LOG``: If ``True``, successful logins will not be logged, so the access log shown in the admin interface will only list unsuccessful login attempts. +* ``AXES_DISABLE_ACCESS_LOG``: If ``True``, disable all access logging, so the admin interface will be empty. Default: ``False`` +* ``AXES_DISABLE_SUCCESS_ACCESS_LOG``: If ``True``, successful logins will not be logged, so the access log shown in the admin interface will only list unsuccessful login attempts. Default: ``False`` diff -Nru django-axes-3.0.3/docs/conf.py django-axes-4.1.0/docs/conf.py --- django-axes-3.0.3/docs/conf.py 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/docs/conf.py 2018-02-18 11:59:07.000000000 +0000 @@ -56,9 +56,9 @@ # built documents. # # The short X.Y version. -version = '2.2.0' +version = '4.1.0' # The full version, including alpha/beta/rc tags. -release = '2.2.0' +release = '4.1.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff -Nru django-axes-3.0.3/docs/development.rst django-axes-4.1.0/docs/development.rst --- django-axes-3.0.3/docs/development.rst 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/docs/development.rst 2018-02-18 11:59:07.000000000 +0000 @@ -12,6 +12,6 @@ Running tests ------------- -Clone the repository and install the django version you want. Then run:: +Clone the repository and install the Django version you want. Then run:: - $ ./runtests.py + $ tox diff -Nru django-axes-3.0.3/docs/usage.rst django-axes-4.1.0/docs/usage.rst --- django-axes-3.0.3/docs/usage.rst 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/docs/usage.rst 2018-02-18 11:59:07.000000000 +0000 @@ -2,19 +2,35 @@ Usage ===== +``django-axes`` listens to signals from ``django.contrib.auth.signals`` to +log access attempts: -Using ``django-axes`` is extremely simple. All you need to do is periodically -check the Access Attempts section of the admin. +* ``user_logged_in`` +* ``user_logged_out`` +* ``user_login_failed`` + +You can also use ``django-axes`` with your own auth module, but you'll need +to ensure that it sends the correct signals in order for ``django-axes`` to +log the access attempts. + +Quickstart +---------- + +Once ``axes`` is in your ``INSTALLED_APPS`` in your project settings file, +you can login and logout of your application via the ``django.contrib.auth`` +views. The access attempts will be logged and visible in the "Access Attempts" +secion of the admin app. By default, django-axes will lock out repeated attempts from the same IP address. You can allow this IP to attempt again by deleting the relevant ``AccessAttempt`` records in the admin. -You can also use the ``axes_reset`` management command using Django's -``manage.py``. +You can also use the ``axes_reset`` and ``axes_reset_user`` management commands +using Django's ``manage.py``. * ``manage.py axes_reset`` will reset all lockouts and access records. * ``manage.py axes_reset ip`` will clear lockout/records for ip +* ``manage.py axes_reset_user username`` will clear lockout/records for an username In your code, you can use ``from axes.utils import reset``. @@ -22,3 +38,139 @@ * ``reset(ip=ip)`` will clear lockout/records for ip * ``reset(username=username)`` will clear lockout/records for a username +Example usage +------------- + +Here is a more detailed example of sending the necessary signals using +`django-axes` 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. + +*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) + +*views.py:* :: + + from django.views.decorators.csrf import csrf_exempt + from django.utils.decorators import method_decorator + from django.http import JsonResponse, HttpResponse + from django.contrib.auth.signals import user_logged_in,\ + user_logged_out,\ + user_login_failed + import json + from myapp.forms import LoginForm + from myapp.auth import custom_authenticate, custom_login + + @method_decorator(csrf_exempt, name='dispatch') + class Login(View): + ''' Custom login view that takes JSON credentials ''' + + http_method_names = ['post',] + + def post(self, request): + # decode post json to dict & validate + post_data = json.loads(request.body.decode('utf-8')) + form = LoginForm(post_data) + + if not form.is_valid(): + # inform axes of failed login + user_login_failed.send( + sender = User, + request = request, + credentials = { + 'username': form.cleaned_data.get('username') + } + ) + return HttpResponse(status=400) + user = custom_authenticate( + request = request, + username = form.cleaned_data.get('username'), + password = form.cleaned_data.get('password'), + ) + + if user is not None: + custom_login(request, user) + user_logged_in.send( + sender = User, + request = request, + user = user, + ) + return JsonResponse({'message':'success!'}, status=200) + else: + 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 myapp.views import Login + + urlpatterns = [ + path('login/', Login.as_view(), name='login'), + ] + +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 ``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. By default ``axes`` is patching only the +``LoginView`` from ``django.contrib.auth`` app and with ``allauth`` you have to +do the patching of views yourself. + +*settings.py:* :: + + AXES_USERNAME_FORM_FIELD = 'login' + +*forms.py:* :: + + from allauth.account.forms import LoginForm + + class AllauthCompatLoginForm(LoginForm): + def user_credentials(self): + credentials = super(AllauthCompatLoginForm, self).user_credentials() + credentials['login'] = credentials.get('email') or credentials.get('username') + return credentials + +*urls.py:* :: + + from allauth.account.views import LoginView + from axes.decorators import axes_dispatch + from axes.decorators import axes_form_invalid + from django.utils.decorators import method_decorator + + from my_app.forms import AllauthCompatLoginForm + + LoginView.dispatch = method_decorator(axes_dispatch)(LoginView.dispatch) + LoginView.form_invalid = method_decorator(axes_form_invalid)(LoginView.form_invalid) + + urlpatterns = [ + # ... + url(r'^accounts/login/$', # Override allauth's default view with a patched view + LoginView.as_view(form_class=AllauthCompatLoginForm), + name="account_login"), + url(r'^accounts/', include('allauth.urls')), + # ... + ] diff -Nru django-axes-3.0.3/manage.py django-axes-4.1.0/manage.py --- django-axes-3.0.3/manage.py 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/manage.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,10 +0,0 @@ -#!/usr/bin/env python -import os -import sys - -if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") - - from django.core.management import execute_from_command_line - - execute_from_command_line(sys.argv) diff -Nru django-axes-3.0.3/runtests.py django-axes-4.1.0/runtests.py --- django-axes-3.0.3/runtests.py 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/runtests.py 2018-02-18 11:59:07.000000000 +0000 @@ -4,13 +4,37 @@ import django from django.conf import settings +from django.core.exceptions import ImproperlyConfigured from django.test.utils import get_runner -if __name__ == '__main__': +def run_tests(): os.environ['DJANGO_SETTINGS_MODULE'] = 'axes.test_settings' django.setup() TestRunner = get_runner(settings) test_runner = TestRunner() failures = test_runner.run_tests(['axes.tests']) sys.exit(bool(failures)) + + +def run_tests_cache(): + """Check that using a wrong cache backend (LocMemCache) throws correctly + + This is due to LocMemCache not working with AccessAttempt caching, + please see issue https://github.com/jazzband/django-axes/issues/288 + """ + + try: + os.environ['DJANGO_SETTINGS_MODULE'] = 'axes.test_settings_cache' + django.setup() + print('Using LocMemCache as a cache backend does not throw') + sys.exit(1) + except ImproperlyConfigured: + print('Using LocMemCache as a cache backend throws correctly') + sys.exit(0) + + +if __name__ == '__main__': + if 'cache' in sys.argv: + run_tests_cache() + run_tests() diff -Nru django-axes-3.0.3/setup.py django-axes-4.1.0/setup.py --- django-axes-3.0.3/setup.py 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/setup.py 2018-02-18 11:59:07.000000000 +0000 @@ -8,10 +8,10 @@ setup( name='django-axes', version=get_version(), - description="Keep track of failed login attempts in Django-powered sites.", + description='Keep track of failed login attempts in Django-powered sites.', long_description=( - codecs.open("README.rst", encoding='utf-8').read() + '\n' + - codecs.open("CHANGES.txt", encoding='utf-8').read()), + codecs.open('README.rst', encoding='utf-8').read() + '\n' + + codecs.open('CHANGES.txt', encoding='utf-8').read()), keywords='authentication django pci security'.split(), author='Josh VanderLinden, Philip Neustrom, Michael Blume, Camilo Nova', author_email='codekoala@gmail.com', @@ -20,7 +20,12 @@ url='https://github.com/jazzband/django-axes', license='MIT', package_dir={'axes': 'axes'}, - install_requires=['pytz', 'django-appconf'], + install_requires=[ + 'pytz', + 'django-appconf', + 'django-ipware', + 'win_inet_pton ; python_version < "3.4" and sys_platform == "win32"' + ], include_package_data=True, packages=find_packages(), classifiers=[ diff -Nru django-axes-3.0.3/tox.ini django-axes-4.1.0/tox.ini --- django-axes-3.0.3/tox.ini 2017-11-23 22:23:47.000000000 +0000 +++ django-axes-4.1.0/tox.ini 2018-02-18 11:59:07.000000000 +0000 @@ -8,15 +8,17 @@ deps = py27: mock django-appconf + django-ipware coveralls django-111: Django>=1.11,<2.0 - django-20: Django>=2.0a1,<2.1 + django-20: Django>=2.0,<2.1 django-master: https://github.com/django/django/archive/master.tar.gz usedevelop = True ignore_outcome = django-master: True commands = coverage run -a --source=axes runtests.py -v2 + coverage run -a --source=axes runtests.py -v2 cache coverage report setenv = PYTHONDONTWRITEBYTECODE=1