mirror of
https://github.com/jazzband/django-axes.git
synced 2026-05-11 00:53:12 +00:00
Merge branch 'master' into cache-attemps
This commit is contained in:
commit
19affea1ba
12 changed files with 269 additions and 56 deletions
33
.travis.yml
33
.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
|
||||
|
|
|
|||
33
CHANGES.txt
33
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]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
__version__ = '2.2.1'
|
||||
__version__ = '2.3.2'
|
||||
|
||||
default_app_config = 'axes.apps.AppConfig'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
3
axes/test_settings_proxy.py
Normal file
3
axes/test_settings_proxy.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .test_settings import *
|
||||
|
||||
AXES_BEHIND_REVERSE_PROXY = True
|
||||
3
axes/test_settings_proxy_custom_header.py
Normal file
3
axes/test_settings_proxy_custom_header.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .test_settings_proxy import *
|
||||
|
||||
AXES_REVERSE_PROXY_HEADER = 'HTTP_X_AXES_CUSTOM_HEADER'
|
||||
147
axes/tests.py
147
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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
15
runtests.py
15
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',
|
||||
])
|
||||
|
|
|
|||
8
runtests_proxy.py
Normal file
8
runtests_proxy.py
Normal 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',
|
||||
])
|
||||
8
runtests_proxy_custom_header.py
Normal file
8
runtests_proxy_custom_header.py
Normal 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',
|
||||
])
|
||||
Loading…
Reference in a new issue