diff --git a/.travis.yml b/.travis.yml index d80005f..5df10d4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,28 +1,17 @@ language: python - python: - - 2.7 - - 3.5 - +- 2.7 +- 3.5 env: - - DJANGO="Django>=1.8,<1.9" - - DJANGO="Django>=1.9,<1.10" - - DJANGO="Django --pre" - +- DJANGO="Django>=1.8,<1.9" +- DJANGO="Django>=1.9,<1.10" +- DJANGO="Django>=1.10,<1.11" install: - - pip install -q $DJANGO coveralls +- pip install -q $DJANGO coveralls script: - - coverage run --source=axes runtests.py - - coverage report - +- coverage run -a --source=axes runtests.py +- coverage run -a --source=axes runtests_proxy.py +- coverage run -a --source=axes runtests_proxy_custom_header.py +- coverage report after_success: - - coveralls - -deploy: - provider: pypi - user: jazzband - distributions: "sdist bdist_wheel" - password: - secure: VD+63Tnv0VYNfFQv9f1KZ0k79HSX8veNk4dTy42Hriteci50z5uSQdZMnqqD83xQJa4VF6N7DHkxHnBVOWLCqGQZeYqR/5BuDFNUewcr6O14dk31HvxMsWDaN1KW0Qwtus8ZrztwGhZtZ/92ODA6luHI4mCTzqX0gcG0/aKd75s= - on: - tags: true +- coveralls diff --git a/CHANGES.txt b/CHANGES.txt index b2f82a9..57ab858 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,9 +1,40 @@ Changes ======= -2.2.1 (2016-09-26) +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] diff --git a/axes/__init__.py b/axes/__init__.py index 833bae8..b068f8a 100644 --- a/axes/__init__.py +++ b/axes/__init__.py @@ -1,4 +1,4 @@ -__version__ = '2.2.1' +__version__ = '2.3.2' default_app_config = 'axes.apps.AppConfig' diff --git a/axes/decorators.py b/axes/decorators.py index b334afe..dacb324 100644 --- a/axes/decorators.py +++ b/axes/decorators.py @@ -1,6 +1,6 @@ import json import logging -from socket import inet_pton, AF_INET6 +from socket import inet_pton, AF_INET6, error from hashlib import md5 from django.contrib.auth import get_user_model @@ -33,21 +33,24 @@ if BEHIND_REVERSE_PROXY: def is_ipv6(ip): try: inet_pton(AF_INET6, ip) - except OSError: + except (OSError, error): return False return True def get_ip(request): - ip = request.META.get('REMOTE_ADDR', '') + """Parse IP address from REMOTE_ADDR or + AXES_REVERSE_PROXY_HEADER if AXES_BEHIND_REVERSE_PROXY is set.""" if BEHIND_REVERSE_PROXY: - ip = request.META.get(REVERSE_PROXY_HEADER, '').split(',', 1)[0] - ip = ip.strip() + # 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 if not given, + # which is the Django calling format for the HTTP X-Forwarder-For header. + # Please see RFC7239 for additional information: + # https://tools.ietf.org/html/rfc7239#section-5 - # IIS seems to add the port number to HTTP_X_FORWARDED_FOR - if ':' in ip: - ip = ip.split(':')[0] + ip = request.META.get(REVERSE_PROXY_HEADER, '') if not ip: raise Warning( @@ -56,11 +59,21 @@ def get_ip(request): 'server settings to make sure this header value is being ' 'passed. Header value {0}'.format(REVERSE_PROXY_HEADER) ) - if not is_ipv6(ip): - # Fix for IIS adding client port number to 'HTTP_X_FORWARDED_FOR' header (removes port number). - ip = ''.join(ip.split(':')[:-1]) - return ip + # X-Forwarded-For IPs can have multiple IPs of which the first one is the + # originating reverse and the rest are proxies that are between the client + ip = ip.split(',', 1)[0] + + # As spaces are permitted between given X-Forwarded-For IP addresses, strip them as well + ip = ip.strip() + + # Fix IIS adding client port number to 'X-Forwarded-For' header (strip port) + if not is_ipv6(ip): + ip = ip.split(':', 1)[0] + + return ip + + return request.META.get('REMOTE_ADDR', '') def query2str(items, max_length=1024): @@ -96,6 +109,12 @@ def is_user_lockable(request): If so, then return the value to see if this user is special and doesn't get their account locked out """ + if hasattr(request.user, 'nolockout'): + return not request.user.nolockout + + if request.method != 'POST': + return True + try: field = getattr(get_user_model(), 'USERNAME_FIELD', 'username') kwargs = { @@ -231,15 +250,16 @@ def watch_login(func): user_agent = request.META.get('HTTP_USER_AGENT', '')[:255] http_accept = request.META.get('HTTP_ACCEPT', '')[:1025] path_info = request.META.get('PATH_INFO', '')[:255] - if login_unsuccessful or not DISABLE_ACCESS_LOG: - AccessLog.objects.create( - user_agent=user_agent, - ip_address=get_ip(request), - username=request.POST.get(USERNAME_FORM_FIELD, None), - http_accept=http_accept, - path_info=path_info, - trusted=not login_unsuccessful, - ) + if not DISABLE_ACCESS_LOG: + if login_unsuccessful or not DISABLE_SUCCESS_ACCESS_LOG: + AccessLog.objects.create( + user_agent=user_agent, + ip_address=get_ip(request), + username=request.POST.get(USERNAME_FORM_FIELD, None), + http_accept=http_accept, + path_info=path_info, + trusted=not login_unsuccessful, + ) if check_request(request, login_unsuccessful): return response diff --git a/axes/settings.py b/axes/settings.py index 9e0beb4..9b9ab92 100644 --- a/axes/settings.py +++ b/axes/settings.py @@ -36,6 +36,8 @@ if (isinstance(COOLOFF_TIME, int) or isinstance(COOLOFF_TIME, float)): DISABLE_ACCESS_LOG = getattr(settings, 'AXES_DISABLE_ACCESS_LOG', False) +DISABLE_SUCCESS_ACCESS_LOG = getattr(settings, 'AXES_DISABLE_SUCCESS_ACCESS_LOG', False) + LOGGER = getattr(settings, 'AXES_LOGGER', 'axes.watch_login') LOCKOUT_TEMPLATE = getattr(settings, 'AXES_LOCKOUT_TEMPLATE', None) diff --git a/axes/test_settings_proxy.py b/axes/test_settings_proxy.py new file mode 100644 index 0000000..2adf793 --- /dev/null +++ b/axes/test_settings_proxy.py @@ -0,0 +1,3 @@ +from .test_settings import * + +AXES_BEHIND_REVERSE_PROXY = True diff --git a/axes/test_settings_proxy_custom_header.py b/axes/test_settings_proxy_custom_header.py new file mode 100644 index 0000000..8e092ae --- /dev/null +++ b/axes/test_settings_proxy_custom_header.py @@ -0,0 +1,3 @@ +from .test_settings_proxy import * + +AXES_REVERSE_PROXY_HEADER = 'HTTP_X_AXES_CUSTOM_HEADER' diff --git a/axes/tests.py b/axes/tests.py index 9adc8aa..bd6c877 100644 --- a/axes/tests.py +++ b/axes/tests.py @@ -4,14 +4,17 @@ import time import json import datetime +from mock import patch + +from django.conf import settings from django.test import TestCase from django.test.utils import override_settings from django.contrib.auth.models import User from django.core.urlresolvers import NoReverseMatch from django.core.urlresolvers import reverse from django.utils import six -from mock import patch +from axes.decorators import get_ip from axes.settings import COOLOFF_TIME from axes.settings import FAILURE_LIMIT from axes.models import AccessAttempt, AccessLog @@ -19,6 +22,11 @@ from axes.signals import user_locked_out from axes.utils import reset, iso8601 +class MockRequest: + def __init__(self): + self.META = dict() + + class AccessAttemptTest(TestCase): """Test case using custom settings for testing """ @@ -305,6 +313,45 @@ class AccessAttemptTest(TestCase): self.assertEquals(response.status_code, 403) self.assertEquals(response.get('Content-Type'), 'application/json') + @patch('axes.decorators.DISABLE_SUCCESS_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.assertEquals(AccessLog.objects.all().count(), 0) + self.assertContains(response, 'Logged out') + + @patch('axes.decorators.DISABLE_SUCCESS_ACCESS_LOG', True) + @patch('axes.decorators.cache.set', return_value=None) + @patch('axes.decorators.cache.get', return_value=None) + def test_non_valid_login_without_success_log(self, cache_set_mock, + cache_get_mock): + """ + A non-valid login does generate an AccessLog when + `DISABLE_SUCCESS_ACCESS_LOG=True`. + """ + AccessLog.objects.all().delete() + + response = self._login(is_valid_username=True, is_valid_password=False) + self.assertEquals(response.status_code, 200) + + self.assertEquals(AccessLog.objects.all().count(), 1) + + @patch('axes.decorators.DISABLE_SUCCESS_ACCESS_LOG', True) + def test_valid_login_without_success_log(self): + """ + A valid login doesn't generate an AccessLog when + `DISABLE_SUCCESS_ACCESS_LOG=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) + @patch('axes.decorators.DISABLE_ACCESS_LOG', True) def test_valid_logout_without_log(self): AccessLog.objects.all().delete() @@ -319,18 +366,22 @@ class AccessAttemptTest(TestCase): @patch('axes.decorators.cache.set', return_value=None) @patch('axes.decorators.cache.get', return_value=None) def test_non_valid_login_without_log(self, cache_set_mock, cache_get_mock): + """ + A non-valid login does generate an AccessLog when + `DISABLE_ACCESS_LOG=True`. + """ AccessLog.objects.all().delete() response = self._login(is_valid_username=True, is_valid_password=False) self.assertEquals(response.status_code, 200) - self.assertEquals(AccessLog.objects.all().count(), 1) + self.assertEquals(AccessLog.objects.all().count(), 0) @patch('axes.decorators.DISABLE_ACCESS_LOG', True) def test_valid_login_without_log(self): """ - A valid login doesn't generate an access attempt when - `AXES_DISABLE_ACCESS_LOG=True`. + A valid login doesn't generate an AccessLog when + `DISABLE_ACCESS_LOG=True`. """ AccessLog.objects.all().delete() @@ -339,6 +390,24 @@ class AccessAttemptTest(TestCase): self.assertEqual(response.status_code, 302) self.assertEqual(AccessLog.objects.all().count(), 0) + @patch('axes.decorators.DISABLE_ACCESS_LOG', True) + def test_check_is_not_made_on_GET(self): + AccessLog.objects.all().delete() + + try: + admin_login = reverse('admin:login') + except NoReverseMatch: + admin_login = reverse('admin:index') + + response = self.client.get(admin_login) + self.assertEqual(response.status_code, 200) + + response = self._login(is_valid_username=True, is_valid_password=True) + self.assertEqual(response.status_code, 302) + + response = self.client.get(reverse('admin:index')) + self.assertEqual(response.status_code, 200) + class UtilsTest(TestCase): def test_iso8601(self): @@ -364,3 +433,73 @@ class UtilsTest(TestCase): } for timedelta, iso_duration in six.iteritems(EXPECTED): self.assertEqual(iso8601(timedelta), iso_duration) + + def test_is_ipv6(self): + from axes.decorators import is_ipv6 + self.assertTrue(is_ipv6('ff80::220:16ff:fec9:1')) + self.assertFalse(is_ipv6('67.255.125.204')) + self.assertFalse(is_ipv6('foo')) + + +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)) + + +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 --git a/docs/configuration.rst b/docs/configuration.rst index 9d17b0f..cebb438 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -49,9 +49,9 @@ These should be defined in your ``settings.py`` file. Default: ``True`` * ``AXES_USERNAME_FORM_FIELD``: the name of the form field that contains your users usernames. Default: ``username`` -* ``AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP``: If ``True`` prevents to login - from IP under particular user if attempts limit exceed, otherwise lock out - based on IP. +* ``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. Default: ``False`` * ``AXES_ONLY_USER_FAILURES`` : If ``True`` only locks based on user id and never locks by IP if attempts limit exceed, otherwise utilize the existing IP and user locking logic @@ -63,4 +63,5 @@ These should be defined in your ``settings.py`` file. 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_DISABLE_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. +* ``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. diff --git a/runtests.py b/runtests.py index 3003d3b..f76e943 100755 --- a/runtests.py +++ b/runtests.py @@ -1,4 +1,5 @@ #!/usr/bin/env python + import os import sys @@ -6,10 +7,18 @@ import django from django.conf import settings from django.test.utils import get_runner -if __name__ == "__main__": - os.environ['DJANGO_SETTINGS_MODULE'] = 'axes.test_settings' + +def run_tests(settings_module, *modules): + os.environ['DJANGO_SETTINGS_MODULE'] = settings_module django.setup() TestRunner = get_runner(settings) test_runner = TestRunner() - failures = test_runner.run_tests(["axes"]) + failures = test_runner.run_tests(*modules) sys.exit(bool(failures)) + + +if __name__ == '__main__': + run_tests('axes.test_settings', [ + 'axes.tests.AccessAttemptTest', + 'axes.tests.UtilsTest', + ]) diff --git a/runtests_proxy.py b/runtests_proxy.py new file mode 100644 index 0000000..6ffa46d --- /dev/null +++ b/runtests_proxy.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python + +from runtests import run_tests + +if __name__ == '__main__': + run_tests('axes.test_settings_proxy', [ + 'axes.tests.GetIPProxyTest', + ]) diff --git a/runtests_proxy_custom_header.py b/runtests_proxy_custom_header.py new file mode 100644 index 0000000..19d5c50 --- /dev/null +++ b/runtests_proxy_custom_header.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python + +from runtests import run_tests + +if __name__ == '__main__': + run_tests('axes.test_settings_proxy_custom_header', [ + 'axes.tests.GetIPProxyCustomHeaderTest', + ])