Merge branch 'master' into cache-attemps

This commit is contained in:
Jorge Galvis 2016-12-06 17:51:19 -05:00
commit 19affea1ba
12 changed files with 269 additions and 56 deletions

View file

@ -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

View file

@ -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]

View file

@ -1,4 +1,4 @@
__version__ = '2.2.1'
__version__ = '2.3.2'
default_app_config = 'axes.apps.AppConfig'

View file

@ -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', '<unknown>')[:255]
http_accept = request.META.get('HTTP_ACCEPT', '<unknown>')[:1025]
path_info = request.META.get('PATH_INFO', '<unknown>')[: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

View file

@ -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)

View file

@ -0,0 +1,3 @@
from .test_settings import *
AXES_BEHIND_REVERSE_PROXY = True

View file

@ -0,0 +1,3 @@
from .test_settings_proxy import *
AXES_REVERSE_PROXY_HEADER = 'HTTP_X_AXES_CUSTOM_HEADER'

View file

@ -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))

View file

@ -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.

View file

@ -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',
])

8
runtests_proxy.py Normal file
View file

@ -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',
])

View file

@ -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',
])