django-axes-4.1.0/0000755000175000017500000000000013242265213012674 5ustar jamesjamesdjango-axes-4.1.0/.gitignore0000644000175000017500000000014213242265213014661 0ustar jamesjames*.egg-info *.pyc .coverage .DS_Store .project .pydevproject .tox build/ dist/ docs/_build test.db django-axes-4.1.0/requirements.txt0000644000175000017500000000003513242265213016156 0ustar jamesjamesDjango sphinx-rtd-theme -e . django-axes-4.1.0/runtests.py0000755000175000017500000000213213242265213015136 0ustar jamesjames#!/usr/bin/env python import os import sys import django from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.test.utils import get_runner 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() django-axes-4.1.0/axes/0000755000175000017500000000000013242265213013634 5ustar jamesjamesdjango-axes-4.1.0/axes/test_settings_cache.py0000644000175000017500000000022713242265213020231 0ustar jamesjamesfrom .test_settings import * AXES_CACHE = 'axes' CACHES = { 'axes': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache' } } django-axes-4.1.0/axes/management/0000755000175000017500000000000013242265213015750 5ustar jamesjamesdjango-axes-4.1.0/axes/management/commands/0000755000175000017500000000000013242265213017551 5ustar jamesjamesdjango-axes-4.1.0/axes/management/commands/axes_reset.py0000644000175000017500000000134013242265213022263 0ustar jamesjamesfrom django.core.management.base import BaseCommand from axes.utils import reset class Command(BaseCommand): help = ("resets any lockouts or failed login records. If called with an " "IP, resets only for that IP") def add_arguments(self, parser): parser.add_argument('ip', nargs='*') def handle(self, *args, **kwargs): count = 0 if kwargs and kwargs.get('ip'): for ip in kwargs['ip'][1:]: count += reset(ip=ip) else: count = reset() if kwargs['verbosity']: if count: self.stdout.write('{0} attempts removed.'.format(count)) else: self.stdout.write('No attempts found.') django-axes-4.1.0/axes/management/commands/axes_reset_user.py0000644000175000017500000000117413242265213023326 0ustar jamesjamesfrom django.core.management.base import BaseCommand from axes.utils import reset class Command(BaseCommand): help = ("Resets any lockouts or failed login records. If called with an " "User name, resets only for that User name") def add_arguments(self, parser): parser.add_argument('username') def handle(self, *args, **kwargs): count = 0 count += reset(username=kwargs['username']) if kwargs['verbosity']: if count: self.stdout.write('{0} attempts removed.'.format(count)) else: self.stdout.write('No attempts found.') django-axes-4.1.0/axes/management/commands/__init__.py0000644000175000017500000000000013242265213021650 0ustar jamesjamesdjango-axes-4.1.0/axes/management/commands/axes_list_attempts.py0000644000175000017500000000072213242265213024040 0ustar jamesjamesfrom django.core.management.base import BaseCommand from axes.models import AccessAttempt class Command(BaseCommand): args = '' help = ('List registered login attempts') def handle(self, *args, **kwargs): for obj in AccessAttempt.objects.all(): self.stdout.write('{ip}\t{username}\t{failures}'.format( ip=obj.ip_address, username=obj.username, failures=obj.failures, )) django-axes-4.1.0/axes/management/__init__.py0000644000175000017500000000000013242265213020047 0ustar jamesjamesdjango-axes-4.1.0/axes/attempts.py0000644000175000017500000001451513242265213016055 0ustar jamesjamesfrom datetime import timedelta from hashlib import md5 from django.contrib.auth import get_user_model 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_axes_cache def _query_user_attempts(request): """Returns access attempt record if it exists. Otherwise return None. """ ip = get_ip(request) username = request.POST.get(settings.AXES_USERNAME_FORM_FIELD, None) if settings.AXES_ONLY_USER_FAILURES: attempts = AccessAttempt.objects.filter(username=username) elif settings.AXES_USE_USER_AGENT: ua = request.META.get('HTTP_USER_AGENT', '')[:255] attempts = AccessAttempt.objects.filter( user_agent=ua, ip_address=ip, username=username, trusted=True ) else: attempts = AccessAttempt.objects.filter( ip_address=ip, username=username, trusted=True ) if not attempts: params = {'trusted': False} if settings.AXES_ONLY_USER_FAILURES: params['username'] = username elif settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP: params['username'] = username params['ip_address'] = ip else: params['ip_address'] = ip if settings.AXES_USE_USER_AGENT: params['user_agent'] = ua attempts = AccessAttempt.objects.filter(**params) return attempts def get_cache_key(request_or_obj): """ Build cache key name from request or AccessAttempt object. :param request_or_obj: Request or AccessAttempt object :return cache-key: String, key to be used in cache system """ if isinstance(request_or_obj, AccessAttempt): ip = request_or_obj.ip_address un = request_or_obj.username ua = request_or_obj.user_agent else: ip = get_ip(request_or_obj) un = request_or_obj.POST.get(settings.AXES_USERNAME_FORM_FIELD, None) ua = request_or_obj.META.get('HTTP_USER_AGENT', '')[:255] ip = ip.encode('utf-8') if ip else ''.encode('utf-8') un = un.encode('utf-8') if un else ''.encode('utf-8') ua = ua.encode('utf-8') if ua else ''.encode('utf-8') if settings.AXES_ONLY_USER_FAILURES: attributes = un elif settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP: attributes = ip + un else: attributes = ip if settings.AXES_USE_USER_AGENT: attributes += ua cache_hash_key = 'axes-{}'.format(md5(attributes).hexdigest()) return cache_hash_key def get_cache_timeout(): """Returns timeout according to COOLOFF_TIME.""" cache_timeout = None cool_off = settings.AXES_COOLOFF_TIME if cool_off: if (isinstance(cool_off, int) or isinstance(cool_off, float)): cache_timeout = timedelta(hours=cool_off).total_seconds() else: cache_timeout = cool_off.total_seconds() return cache_timeout def get_user_attempts(request): force_reload = False attempts = _query_user_attempts(request) cache_hash_key = get_cache_key(request) cache_timeout = get_cache_timeout() cool_off = settings.AXES_COOLOFF_TIME if cool_off: if (isinstance(cool_off, int) or isinstance(cool_off, float)): cool_off = timedelta(hours=cool_off) for attempt in attempts: if attempt.attempt_time + cool_off < timezone.now(): if attempt.trusted: attempt.failures_since_start = 0 attempt.save() get_axes_cache().set(cache_hash_key, 0, cache_timeout) else: attempt.delete() force_reload = True failures_cached = get_axes_cache().get(cache_hash_key) if failures_cached is not None: get_axes_cache().set( cache_hash_key, failures_cached - 1, cache_timeout ) # If objects were deleted, we need to update the queryset to reflect this, # so force a reload. if force_reload: attempts = _query_user_attempts(request) return attempts def ip_in_whitelist(ip): if not settings.AXES_IP_WHITELIST: return False return ip in settings.AXES_IP_WHITELIST def ip_in_blacklist(ip): if not settings.AXES_IP_BLACKLIST: return False return ip in settings.AXES_IP_BLACKLIST def is_user_lockable(request): """Check if the user has a profile with nolockout 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 = { field: request.POST.get(settings.AXES_USERNAME_FORM_FIELD) } user = get_user_model().objects.get(**kwargs) if hasattr(user, 'nolockout'): # need to invert since we need to return # false for users that can't be blocked return not user.nolockout except get_user_model().DoesNotExist: # not a valid user return True # Default behavior for a user to be lockable return True def is_already_locked(request): ip = get_ip(request) if ( settings.AXES_ONLY_USER_FAILURES or settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP ) and request.method == 'GET': return False if settings.AXES_NEVER_LOCKOUT_WHITELIST and ip_in_whitelist(ip): return False if settings.AXES_ONLY_WHITELIST and not ip_in_whitelist(ip): return True if ip_in_blacklist(ip): return True if not is_user_lockable(request): return False cache_hash_key = get_cache_key(request) failures_cached = get_axes_cache().get(cache_hash_key) if failures_cached is not None: return ( failures_cached >= settings.AXES_FAILURE_LIMIT and settings.AXES_LOCK_OUT_AT_FAILURE ) else: for attempt in get_user_attempts(request): if ( attempt.failures_since_start >= settings.AXES_FAILURE_LIMIT and settings.AXES_LOCK_OUT_AT_FAILURE ): return True return False django-axes-4.1.0/axes/tests/0000755000175000017500000000000013242265213014776 5ustar jamesjamesdjango-axes-4.1.0/axes/tests/test_access_attempt.py0000644000175000017500000003400113242265213021404 0ustar jamesjamesimport datetime import hashlib import json import random import string import time 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 from axes.conf import settings from axes.attempts import get_cache_key from axes.models import AccessAttempt, AccessLog from axes.signals import user_locked_out from axes.tests.compatibility import patch from axes.utils import reset @override_settings(AXES_COOLOFF_TIME=datetime.timedelta(seconds=2)) class AccessAttemptTest(TestCase): """Test case using custom settings for testing """ VALID_USERNAME = 'valid-username' VALID_PASSWORD = 'valid-password' LOCKED_MESSAGE = 'Account locked: too many login attempts.' LOGIN_FORM_KEY = '' def _login(self, is_valid_username=False, is_valid_password=False, is_json=False, **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: # Use a valid username username = self.VALID_USERNAME else: # Generate a wrong random username chars = string.ascii_uppercase + string.digits username = ''.join(random.choice(chars) for x in range(10)) if is_valid_password: password = self.VALID_PASSWORD else: password = 'invalid-password' headers = { 'user_agent': 'test-browser' } post_data = { 'username': username, 'password': password, 'this_is_the_login_form': 1, } post_data.update(kwargs) if is_json: headers.update({ 'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest', 'content_type': 'application/json', }) post_data = json.dumps(post_data) response = self.client.post( reverse('admin:login'), post_data, **headers ) return response def setUp(self): """Create a valid user for login """ self.user = User.objects.create_superuser( username=self.VALID_USERNAME, email='test@example.com', password=self.VALID_PASSWORD, ) def test_failure_limit_once(self): """Tests the login lock trying to login one more time than failure limit """ # test until one try before the limit for i in range(1, settings.AXES_FAILURE_LIMIT): response = self._login() # Check if we are in the same login page self.assertContains(response, self.LOGIN_FORM_KEY) # So, we shouldn't have gotten a lock-out yet. # But we should get one now response = self._login() self.assertContains(response, self.LOCKED_MESSAGE, status_code=403) def test_failure_limit_many(self): """Tests the login lock trying to login a lot of times more than failure limit """ for i in range(1, settings.AXES_FAILURE_LIMIT): response = self._login() # Check if we are in the same login page self.assertContains(response, self.LOGIN_FORM_KEY) # So, we shouldn't have gotten a lock-out yet. # We should get a locked message each time we try again for i in range(0, random.randrange(1, settings.AXES_FAILURE_LIMIT)): response = self._login() self.assertContains(response, self.LOCKED_MESSAGE, status_code=403) def test_valid_login(self): """Tests a valid login for a real username """ response = self._login(is_valid_username=True, is_valid_password=True) self.assertNotContains(response, self.LOGIN_FORM_KEY, status_code=302) def test_valid_logout(self): """Tests a valid logout and make sure the logout_time is updated """ response = self._login(is_valid_username=True, is_valid_password=True) self.assertEquals(AccessLog.objects.latest('id').logout_time, None) response = self.client.get(reverse('admin:logout')) self.assertNotEquals(AccessLog.objects.latest('id').logout_time, None) self.assertContains(response, 'Logged out') def test_cooling_off(self): """Tests if the cooling time allows a user to login """ self.test_failure_limit_once() # Wait for the cooling off period time.sleep(settings.AXES_COOLOFF_TIME.total_seconds()) # It should be possible to login again, make sure it is. self.test_valid_login() def test_cooling_off_for_trusted_user(self): """Test the cooling time for a trusted user """ # Test successful login-logout, this makes the user trusted. self.test_valid_logout() # Try the cooling off time self.test_cooling_off() def test_long_user_agent_valid(self): """Tests if can handle a long user agent """ long_user_agent = 'ie6' * 1024 response = self._login( is_valid_username=True, is_valid_password=True, user_agent=long_user_agent, ) self.assertNotContains(response, self.LOGIN_FORM_KEY, status_code=302) def test_long_user_agent_not_valid(self): """Tests if can handle a long user agent with failure """ long_user_agent = 'ie6' * 1024 for i in range(0, settings.AXES_FAILURE_LIMIT + 1): response = self._login(user_agent=long_user_agent) self.assertContains(response, self.LOCKED_MESSAGE, status_code=403) def test_reset_ip(self): """Tests if can reset an ip address """ # Make a lockout self.test_failure_limit_once() # Reset the ip so we can try again reset(ip='127.0.0.1') # Make a login attempt again self.test_valid_login() def test_reset_all(self): """Tests if can reset all attempts """ # Make a lockout self.test_failure_limit_once() # Reset all attempts so we can try again reset() # Make a login attempt again self.test_valid_login() @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 ip_address = '127.0.0.1' cache_hash_key = 'axes-{}'.format( hashlib.md5(ip_address.encode()).hexdigest() ) request_factory = RequestFactory() request = request_factory.post('/admin/login/', data={ 'username': self.VALID_USERNAME, 'password': 'test' }) self.assertEqual(cache_hash_key, get_cache_key(request)) # Getting cache key from AccessAttempt Object attempt = AccessAttempt( user_agent='', ip_address=ip_address, username=self.VALID_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_cache_key(attempt)) def test_send_lockout_signal(self): """Test if the lockout signal is emitted """ # this "hack" is needed so we don't have to use global variables or python3 features class Scope(object): pass scope = Scope() scope.signal_received = 0 def signal_handler(request, username, ip_address, *args, **kwargs): scope.signal_received += 1 self.assertIsNotNone(request) # Connect signal handler user_locked_out.connect(signal_handler) # Make a lockout self.test_failure_limit_once() self.assertEquals(scope.signal_received, 1) reset() # Make another lockout self.test_failure_limit_once() self.assertEquals(scope.signal_received, 2) @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True) def test_lockout_by_combination_user_and_ip(self): """Tests the login lock with a valid username and invalid password when AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP is True """ # test until one try before the limit for i 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) # 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_user_only(self): """Tests the login lock with a valid username and invalid password when AXES_ONLY_USER_FAILURES is True """ # test until one try before the limit for i 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) # 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) # reset the username only and make sure we can log in now even though # our IP has failed each time reset(username=AccessAttemptTest.VALID_USERNAME) response = self._login( is_valid_username=True, is_valid_password=True, ) # Check if we are still in the login page self.assertNotContains(response, self.LOGIN_FORM_KEY, status_code=302) # now create failure_limit + 1 failed logins and then we should still # be able to login with valid_username for i in range(1, settings.AXES_FAILURE_LIMIT + 1): response = self._login( is_valid_username=False, is_valid_password=False, ) # Check if we can still log in with valid user response = self._login(is_valid_username=True, is_valid_password=True) self.assertNotContains(response, self.LOGIN_FORM_KEY, status_code=302) def test_log_data_truncated(self): """Tests that query2str properly truncates data to the max_length (default 1024) """ # An impossibly large post dict extra_data = {string.ascii_letters * x: x for x in range(0, 1000)} self._login(**extra_data) self.assertEquals( len(AccessAttempt.objects.latest('id').post_data), 1024 ) def test_json_response(self): """Tests response content type and status code for the ajax request """ self.test_failure_limit_once() response = self._login(is_json=True) self.assertEquals(response.status_code, 403) self.assertEquals(response.get('Content-Type'), 'application/json') @override_settings(AXES_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') @override_settings(AXES_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) @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.assertEquals(AccessLog.objects.first().logout_time, None) self.assertContains(response, 'Logged out') @override_settings(AXES_DISABLE_ACCESS_LOG=True) def test_non_valid_login_without_log(self): """ 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(), 0) @override_settings(AXES_DISABLE_ACCESS_LOG=True) def test_check_is_not_made_on_GET(self): AccessLog.objects.all().delete() response = self.client.get(reverse('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) 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) django-axes-4.1.0/axes/tests/test_access_attempt_config.py0000644000175000017500000003744713242265213022752 0ustar jamesjamesimport json from django.test import TestCase, override_settings from django.urls import reverse from django.contrib.auth.models import User from axes.conf import settings class AccessAttemptConfigTest(TestCase): """ This set of tests checks 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' USER_1 = 'valid-user-1' USER_2 = 'valid-user-2' VALID_PASSWORD = 'valid-password' WRONG_PASSWORD = 'wrong-password' LOCKED_MESSAGE = 'Account locked: too many login attempts.' LOGIN_FORM_KEY = '' ALLOWED = 302 BLOCKED = 403 def _login(self, username, password, ip_addr='127.0.0.1', is_json=False, **kwargs): """Login a user and get the response. IP address can be configured to test IP blocking functionality. """ headers = { 'user_agent': 'test-browser' } post_data = { 'username': username, 'password': password, 'this_is_the_login_form': 1, } post_data.update(kwargs) if is_json: headers.update({ 'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest', 'content_type': 'application/json', }) post_data = json.dumps(post_data) response = self.client.post( reverse('admin:login'), post_data, REMOTE_ADDR=ip_addr, **headers ) return response def _lockout_user_from_ip(self, username, ip_addr): for i in range(1, settings.AXES_FAILURE_LIMIT + 1): 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. """ self.user = User.objects.create_superuser( username=self.USER_1, email='test_1@example.com', password=self.VALID_PASSWORD, ) self.user = User.objects.create_superuser( username=self.USER_2, email='test_2@example.com', password=self.VALID_PASSWORD, ) # 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, cache_get_mock=None, cache_set_mock=None ): # 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) # 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, cache_get_mock=None, cache_set_mock=None ): # 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) # 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, cache_get_mock=None, cache_set_mock=None ): # 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) # 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) def test_lockout_by_user_and_ip_with_empty_username_allows_other_users_using_cache( self, cache_get_mock=None, cache_set_mock=None ): # 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) django-axes-4.1.0/axes/tests/test_utils.py0000644000175000017500000001251013242265213017546 0ustar jamesjamesimport datetime from django.test import TestCase, override_settings from django.utils import six from axes.utils import iso8601, is_ipv6, get_client_str class UtilsTest(TestCase): def test_iso8601(self): """Tests iso8601 correctly translates datetime.timdelta to ISO 8601 formatted duration.""" EXPECTED = { datetime.timedelta(days=1, hours=25, minutes=42, seconds=8): 'P2DT1H42M8S', datetime.timedelta(days=7, seconds=342): 'P7DT5M42S', datetime.timedelta(days=0, hours=2, minutes=42): 'PT2H42M', datetime.timedelta(hours=20, seconds=42): 'PT20H42S', datetime.timedelta(seconds=300): 'PT5M', datetime.timedelta(seconds=9005): 'PT2H30M5S', datetime.timedelta(minutes=9005): 'P6DT6H5M', datetime.timedelta(days=15): 'P15D' } for timedelta, iso_duration in six.iteritems(EXPECTED): self.assertEqual(iso8601(timedelta), iso_duration) def test_is_ipv6(self): self.assertTrue(is_ipv6('ff80::220:16ff:fec9:1')) self.assertFalse(is_ipv6('67.255.125.204')) self.assertFalse(is_ipv6('foo')) @override_settings(AXES_VERBOSE=True) def test_verbose_ip_only_client_details(self): username = 'test@example.com' ip = '127.0.0.1' user_agent = 'Googlebot/2.1 (+http://www.googlebot.com/bot.html)' path_info = '/admin/' details = "{{user: '{0}', ip: '{1}', user-agent: '{2}', path: '{3}'}}" expected = details.format(username, ip, user_agent, path_info) actual = get_client_str(username, ip, user_agent, path_info) self.assertEqual(expected, actual) @override_settings(AXES_VERBOSE=False) def test_non_verbose_ip_only_client_details(self): username = 'test@example.com' ip = '127.0.0.1' user_agent = 'Googlebot/2.1 (+http://www.googlebot.com/bot.html)' path_info = '/admin/' expected = ip actual = get_client_str(username, ip, user_agent, path_info) 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 = '127.0.0.1' user_agent = 'Googlebot/2.1 (+http://www.googlebot.com/bot.html)' path_info = '/admin/' details = "{{user: '{0}', ip: '{1}', user-agent: '{2}', path: '{3}'}}" expected = details.format(username, ip, user_agent, path_info) actual = get_client_str(username, ip, user_agent, path_info) 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 = '127.0.0.1' user_agent = 'Googlebot/2.1 (+http://www.googlebot.com/bot.html)' path_info = '/admin/' expected = username actual = get_client_str(username, ip, user_agent, path_info) 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 = '127.0.0.1' user_agent = 'Googlebot/2.1 (+http://www.googlebot.com/bot.html)' path_info = '/admin/' details = "{{user: '{0}', ip: '{1}', user-agent: '{2}', path: '{3}'}}" expected = details.format(username, ip, user_agent, path_info) actual = get_client_str(username, ip, user_agent, path_info) 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 = '127.0.0.1' user_agent = 'Googlebot/2.1 (+http://www.googlebot.com/bot.html)' path_info = '/admin/' expected = '{0} from {1}'.format(username, ip) actual = get_client_str(username, ip, user_agent, path_info) 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 = '127.0.0.1' user_agent = 'Googlebot/2.1 (+http://www.googlebot.com/bot.html)' path_info = '/admin/' details = "{{user: '{0}', ip: '{1}', user-agent: '{2}', path: '{3}'}}" expected = details.format(username, ip, user_agent, path_info) actual = get_client_str(username, ip, user_agent, path_info) 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 = '127.0.0.1' user_agent = 'Googlebot/2.1 (+http://www.googlebot.com/bot.html)' path_info = '/admin/' expected = ip + '(user-agent={0})'.format(user_agent) actual = get_client_str(username, ip, user_agent, path_info) self.assertEqual(expected, actual) django-axes-4.1.0/axes/tests/__init__.py0000644000175000017500000000000013242265213017075 0ustar jamesjamesdjango-axes-4.1.0/axes/tests/compatibility.py0000644000175000017500000000013013242265213020213 0ustar jamesjamestry: from unittest.mock import patch except ImportError: from mock import patch django-axes-4.1.0/axes/test_urls.py0000644000175000017500000000017313242265213016233 0ustar jamesjamesfrom django.conf.urls import url from django.contrib import admin urlpatterns = [ url(r'^admin/', admin.site.urls), ] django-axes-4.1.0/axes/test_settings.py0000644000175000017500000000312713242265213017110 0ustar jamesjamesDATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:', } } CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.dummy.DummyCache' } } SITE_ID = 1 MIDDLEWARE = ( 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', ) ROOT_URLCONF = 'axes.test_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_L10N = False USE_TZ = False LOGIN_REDIRECT_URL = '/admin/' AXES_FAILURE_LIMIT = 10 django-axes-4.1.0/axes/signals.py0000644000175000017500000001416013242265213015650 0ustar jamesjamesimport logging 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.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 from axes.attempts import get_user_attempts from axes.attempts import is_user_lockable from axes.attempts import ip_in_whitelist from axes.models import AccessLog, AccessAttempt from axes.utils import get_client_str from axes.utils import query2str from axes.utils import get_axes_cache log = logging.getLogger(settings.AXES_LOGGER) user_locked_out = Signal(providing_args=['request', 'username', 'ip_address']) @receiver(user_login_failed) 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[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] if settings.AXES_NEVER_LOCKOUT_WHITELIST and ip_in_whitelist(ip_address): return failures = 0 attempts = get_user_attempts(request) cache_hash_key = get_cache_key(request) cache_timeout = get_cache_timeout() failures_cached = get_axes_cache().get(cache_hash_key) if failures_cached is not None: failures = failures_cached else: for attempt in attempts: failures = max(failures, attempt.failures_since_start) # add a failed attempt for this user failures += 1 get_axes_cache().set(cache_hash_key, failures, cache_timeout) # has already attempted, update the info if len(attempts): for attempt in attempts: attempt.get_data = '%s\n---------\n%s' % ( attempt.get_data, query2str(request.GET), ) attempt.post_data = '%s\n---------\n%s' % ( attempt.post_data, query2str(request.POST) ) attempt.http_accept = http_accept attempt.path_info = path_info attempt.failures_since_start = failures attempt.attempt_time = timezone.now() attempt.save() fail_msg = 'AXES: Repeated login failure by {0}.'.format( get_client_str(username, ip_address, user_agent, path_info) ) count_msg = 'Count = {0} of {1}'.format( failures, settings.AXES_FAILURE_LIMIT ) log.info('{0} {1}'.format(fail_msg, count_msg)) else: # Record failed attempt. Whether or not the IP address or user agent is # used in counting failures is handled elsewhere, so we just record # everything here. AccessAttempt.objects.create( user_agent=user_agent, ip_address=ip_address, username=username, get_data=query2str(request.GET), post_data=query2str(request.POST), http_accept=http_accept, path_info=path_info, failures_since_start=failures, ) log.info( 'AXES: New login failure by {0}. Creating access record.'.format( get_client_str(username, ip_address, user_agent, path_info) ) ) # no matter what, we want to lock them out if they're past the number of # attempts allowed, unless the user is set to notlockable if ( failures >= settings.AXES_FAILURE_LIMIT and settings.AXES_LOCK_OUT_AT_FAILURE and is_user_lockable(request) ): log.warning('AXES: locked out {0} after repeated login attempts.'.format( get_client_str(username, ip_address, user_agent, path_info) )) # send signal when someone is locked out. user_locked_out.send( 'axes', request=request, username=username, ip_address=ip_address ) @receiver(user_logged_in) def log_user_logged_in(sender, request, user, **kwargs): """ When a user logs in, update the access log """ username = user.get_username() ip_address = get_ip(request) 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] log.info('AXES: Successful login by {0}.'.format( get_client_str(username, ip_address, user_agent, path_info) )) if not settings.AXES_DISABLE_SUCCESS_ACCESS_LOG: AccessLog.objects.create( user_agent=user_agent, ip_address=ip_address, username=username, http_accept=http_accept, path_info=path_info, trusted=True, ) @receiver(user_logged_out) def log_user_logged_out(sender, request, user, **kwargs): """ When a user logs out, update the access log """ log.info('AXES: Successful logout by {0}.'.format(user)) if user and not settings.AXES_DISABLE_ACCESS_LOG: AccessLog.objects.filter( username=user.get_username(), logout_time__isnull=True, ).update(logout_time=timezone.now()) @receiver(post_save, sender=AccessAttempt) def update_cache_after_save(instance, **kwargs): cache_hash_key = get_cache_key(instance) if not get_axes_cache().get(cache_hash_key): cache_timeout = get_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) get_axes_cache().delete(cache_hash_key) django-axes-4.1.0/axes/decorators.py0000644000175000017500000000455013242265213016357 0ustar jamesjamesfrom datetime import timedelta from functools import wraps import json import logging from django.http import HttpResponse from django.http import HttpResponseRedirect from django.shortcuts import render from axes import get_version from axes.conf import settings from axes.attempts import is_already_locked from axes.utils import iso8601 log = logging.getLogger(settings.AXES_LOGGER) if settings.AXES_VERBOSE: log.info('AXES: BEGIN LOG') log.info('AXES: Using django-axes ' + get_version()) if settings.AXES_ONLY_USER_FAILURES: log.info('AXES: blocking by username only.') elif settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP: log.info('AXES: blocking by combination of username and IP.') else: log.info('AXES: blocking by IP only.') def axes_dispatch(func): def inner(request, *args, **kwargs): if is_already_locked(request): return lockout_response(request) return func(request, *args, **kwargs) return inner def axes_form_invalid(func): @wraps(func) def inner(self, *args, **kwargs): if is_already_locked(self.request): return lockout_response(self.request) return func(self, *args, **kwargs) return inner def lockout_response(request): context = { 'failure_limit': settings.AXES_FAILURE_LIMIT, 'username': request.POST.get(settings.AXES_USERNAME_FORM_FIELD, '') } cool_off = settings.AXES_COOLOFF_TIME if cool_off: if (isinstance(cool_off, int) or isinstance(cool_off, float)): cool_off = timedelta(hours=cool_off) context.update({ 'cooloff_time': iso8601(cool_off) }) if request.is_ajax(): return HttpResponse( json.dumps(context), content_type='application/json', status=403, ) elif settings.AXES_LOCKOUT_TEMPLATE: return render( request, settings.AXES_LOCKOUT_TEMPLATE, context, status=403 ) elif settings.AXES_LOCKOUT_URL: return HttpResponseRedirect(settings.AXES_LOCKOUT_URL) else: msg = 'Account locked: too many login attempts. {0}' if settings.AXES_COOLOFF_TIME: msg = msg.format('Please try again later.') else: msg = msg.format('Contact an admin to unlock your account.') return HttpResponse(msg, status=403) django-axes-4.1.0/axes/models.py0000644000175000017500000000315513242265213015475 0ustar jamesjamesfrom django.db import models class CommonAccess(models.Model): user_agent = models.CharField( max_length=255, db_index=True, ) ip_address = models.GenericIPAddressField( verbose_name='IP Address', null=True, db_index=True, ) username = models.CharField( max_length=255, null=True, db_index=True, ) # Once a user logs in from an ip, that combination is trusted and not # locked out in case of a distributed attack trusted = models.BooleanField( default=False, db_index=True, ) http_accept = models.CharField( verbose_name='HTTP Accept', max_length=1025, ) path_info = models.CharField( verbose_name='Path', max_length=255, ) attempt_time = models.DateTimeField( auto_now_add=True, ) class Meta: app_label = 'axes' abstract = True ordering = ['-attempt_time'] class AccessAttempt(CommonAccess): 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', ) @property def failures(self): return self.failures_since_start def __str__(self): return 'Attempted Access: %s' % self.attempt_time class AccessLog(CommonAccess): logout_time = models.DateTimeField( null=True, blank=True, ) def __str__(self): return 'Access Log for %s @ %s' % (self.username, self.attempt_time) django-axes-4.1.0/axes/__init__.py0000644000175000017500000000015613242265213015747 0ustar jamesjames__version__ = '4.1.0' default_app_config = 'axes.apps.AppConfig' def get_version(): return __version__ django-axes-4.1.0/axes/utils.py0000644000175000017500000000520013242265213015343 0ustar jamesjamesfrom 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, 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. If there's a field called "password" it will be excluded from the output. The length of the output is limited to max_length to avoid a DoS attack via excessively large payloads. """ return '\n'.join([ '%s=%s' % (k, v) for k, v in six.iteritems(items) if k != settings.AXES_PASSWORD_FORM_FIELD ][:int(max_length / 2)])[:max_length] def get_client_str(username, ip_address, user_agent=None, path_info=None): if settings.AXES_VERBOSE: if isinstance(path_info, tuple): path_info = path_info[0] details = "{{user: '{0}', ip: '{1}', user-agent: '{2}', path: '{3}'}}" return details.format(username, ip_address, user_agent, path_info) if settings.AXES_ONLY_USER_FAILURES: client = username elif settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP: client = '{0} from {1}'.format(username, ip_address) else: client = ip_address if settings.AXES_USE_USER_AGENT: client += '(user-agent={0})'.format(user_agent) return client def is_ipv6(ip): try: inet_pton(AF_INET6, ip) except (OSError, error): return False return True def reset(ip=None, username=None): """Reset records that match ip or username, and return the count of removed attempts. """ attempts = AccessAttempt.objects.all() if ip: attempts = attempts.filter(ip_address=ip) if username: attempts = attempts.filter(username=username) count, _ = attempts.delete() return count def iso8601(timestamp): """Returns datetime.timedelta translated to ISO 8601 formatted duration. """ seconds = timestamp.total_seconds() minutes, seconds = divmod(seconds, 60) hours, minutes = divmod(minutes, 60) days, hours = divmod(hours, 24) date = '{:.0f}D'.format(days) if days else '' time_values = hours, minutes, seconds time_designators = 'H', 'M', 'S' time = ''.join([ ('{:.0f}'.format(value) + designator) for value, designator in zip(time_values, time_designators) if value] ) return 'P' + date + ('T' + time if time else '') django-axes-4.1.0/axes/admin.py0000644000175000017500000000422113242265213015275 0ustar jamesjamesfrom django.contrib import admin from axes.models import AccessLog from axes.models import AccessAttempt 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', 'trusted', 'http_accept', 'path_info', 'attempt_time', 'get_data', 'post_data', 'failures_since_start' ] def has_add_permission(self, request, obj=None): return False admin.site.register(AccessAttempt, AccessAttemptAdmin) 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', 'trusted', 'http_accept', 'path_info', 'attempt_time', 'logout_time' ] def has_add_permission(self, request, obj=None): return False admin.site.register(AccessLog, AccessLogAdmin) django-axes-4.1.0/axes/apps.py0000644000175000017500000000201713242265213015151 0ustar jamesjamesfrom django import apps class AppConfig(apps.AppConfig): 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 from axes.decorators import axes_dispatch from axes.decorators import axes_form_invalid LoginView.dispatch = method_decorator(axes_dispatch)(LoginView.dispatch) LoginView.form_invalid = method_decorator(axes_form_invalid)(LoginView.form_invalid) django-axes-4.1.0/axes/conf.py0000644000175000017500000000221413242265213015132 0ustar jamesjamesfrom django.conf import settings from appconf import AppConf class MyAppConf(AppConf): # see if the user has overridden the failure limit FAILURE_LIMIT = 3 # see if the user has set axes to lock out logins after failure limit LOCK_OUT_AT_FAILURE = True USE_USER_AGENT = False # use a specific username field to retrieve from login POST data USERNAME_FORM_FIELD = 'username' # use a specific password field to retrieve from login POST data PASSWORD_FORM_FIELD = 'password' # only check user name and not location or user_agent ONLY_USER_FAILURES = False # lock out user from particular IP based on combination USER+IP LOCK_OUT_BY_COMBINATION_USER_AND_IP = False DISABLE_ACCESS_LOG = False DISABLE_SUCCESS_ACCESS_LOG = False LOGGER = 'axes.watch_login' LOCKOUT_TEMPLATE = None LOCKOUT_URL = None COOLOFF_TIME = None VERBOSE = True # whitelist and blacklist # TODO: convert the strings to IPv4 on startup to avoid type conversion during processing NEVER_LOCKOUT_WHITELIST = False ONLY_WHITELIST = False IP_WHITELIST = None IP_BLACKLIST = None django-axes-4.1.0/axes/migrations/0000755000175000017500000000000013242265213016010 5ustar jamesjamesdjango-axes-4.1.0/axes/migrations/0003_auto_20160322_0929.py0000644000175000017500000000366013242265213021443 0ustar jamesjames# -*- coding: utf-8 -*- from __future__ import unicode_literals from 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-4.1.0/axes/migrations/0002_auto_20151217_2044.py0000644000175000017500000000331713242265213021432 0ustar jamesjames# -*- coding: utf-8 -*- from __future__ import unicode_literals from 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-4.1.0/axes/migrations/0001_initial.py0000644000175000017500000000445013242265213020456 0ustar jamesjames# -*- coding: utf-8 -*- from __future__ import unicode_literals from 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-4.1.0/axes/migrations/__init__.py0000644000175000017500000000000013242265213020107 0ustar jamesjamesdjango-axes-4.1.0/.travis.yml0000644000175000017500000000103713242265213015006 0ustar jamesjamessudo: false language: python cache: pip python: - 2.7 - 3.4 - 3.5 - 3.6 install: pip install tox-travis script: tox after_success: - coveralls deploy: provider: pypi user: jazzband server: https://jazzband.co/projects/django-axes/upload distributions: sdist bdist_wheel password: secure: TCH5tGIggL2wsWce2svMwpEpPiwVOYqq1R3uSBTexszleP0OafNq/wZk2KZEReR5w1Aq68qp5F5Eeh2ZjJTq4f9M4LtTvqQzrmyNP55DYk/uB1rBJm9b4gBgMtAknxdI2g7unkhQEDo4suuPCVofM7rrDughySNpmvlUQYDttHQ= on: tags: true repo: jazzband/django-axes python: 3.6 django-axes-4.1.0/MANIFEST.in0000644000175000017500000000012713242265213014432 0ustar jamesjamesinclude LICENSE README.rst CHANGES.txt recursive-include axes *.py include .travis.yml django-axes-4.1.0/CHANGES.txt0000644000175000017500000002576113242265213014520 0ustar jamesjamesChanges ======= 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 [Radosław 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. [Артур Муллахметов] - 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-4.1.0/docs/0000755000175000017500000000000013242265213013624 5ustar jamesjamesdjango-axes-4.1.0/docs/usage.rst0000644000175000017500000001416213242265213015466 0ustar jamesjames.. _usage: Usage ===== ``django-axes`` listens to signals from ``django.contrib.auth.signals`` to log access attempts: * ``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`` 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``. * ``reset()`` will reset all lockouts and access records. * ``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')), # ... ] django-axes-4.1.0/docs/Makefile0000644000175000017500000001640113242265213015266 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-4.1.0/docs/index.rst0000644000175000017500000000077013242265213015471 0ustar jamesjames.. _index: .. Django Axes documentation master file, created by sphinx-quickstart on Sat Jul 30 16:37:41 2016. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Welcome to Django Axes's documentation! ======================================= Contents -------- .. toctree:: :maxdepth: 2 installation configuration usage requirements development captcha Indices and tables ------------------ * :ref:`search` django-axes-4.1.0/docs/requirements.rst0000644000175000017500000000053613242265213017105 0ustar jamesjames.. _requirements: Requirements ============ ``django-axes`` requires a supported Django version. The application is intended to work around the Django admin and the regular ``django.contrib.auth`` login-powered pages. Look here https://github.com/jazzband/django-axes/blob/master/.travis.yml to check if your django / python version are supported. django-axes-4.1.0/docs/development.rst0000644000175000017500000000073213242265213016702 0ustar jamesjames.. _development: Development =========== You can contribute to this project forking it from github and sending pull requests. This is a `Jazzband `_ project. By contributing you agree to abide by the `Contributor Code of Conduct `_ and follow the `guidelines `_. Running tests ------------- Clone the repository and install the Django version you want. Then run:: $ tox django-axes-4.1.0/docs/configuration.rst0000644000175000017500000001105613242265213017230 0ustar jamesjames.. _configuration: Configuration ============= Just add `axes` to your ``INSTALLED_APPS``:: INSTALLED_APPS = ( 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.sites', ... 'axes', ... ) 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 ---------------- 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 are exceeded, should we lock out this IP (and optional user agent)? Default: ``True`` * ``AXES_USE_USER_AGENT``: If ``True``, lock out / log based on an IP address AND a user agent. This means requests from different user agents but from the same IP are treated differently. Default: ``False`` * ``AXES_COOLOFF_TIME``: If set, defines a period of inactivity after which old failed login attempts will be forgotten. Can be set to a python timedelta object or an integer. If an integer, will be interpreted as a number of hours. Default: ``None`` * ``AXES_LOGGER``: If set, specifies a logging mechanism for axes to use. Default: ``'axes.watch_login'`` * ``AXES_LOCKOUT_TEMPLATE``: If set, specifies a template to render when a user is locked out. Template receives cooloff_time and failure_limit as context variables. Default: ``None`` * ``AXES_LOCKOUT_URL``: 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. Default: ``None`` * ``AXES_VERBOSE``: If ``True``, you'll see slightly more logging for Axes. 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. 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 Default: ``False`` * ``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: [] Default: ``False`` * ``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`` django-axes-4.1.0/docs/captcha.rst0000644000175000017500000000226513242265213015766 0ustar jamesjames.. _captcha: Using a captcha =============== Using https://github.com/mbi/django-simple-captcha you do the following: 1. Change axes lockout url in ``settings.py``:: AXES_LOCKOUT_URL = '/locked' 2. Add the url in ``urls.py``:: url(r'^locked/$', locked_out, name='locked_out'), 3. Create a captcha form:: class AxesCaptchaForm(forms.Form): captcha = CaptchaField() 4. Create a captcha view for the above url that resets on captcha success and redirects:: def locked_out(request): if request.POST: form = AxesCaptchaForm(request.POST) if form.is_valid(): ip = get_ip_address_from_request(request) reset(ip=ip) return HttpResponseRedirect(reverse_lazy('signin')) else: form = AxesCaptchaForm() return render_to_response('locked_out.html', dict(form=form), context_instance=RequestContext(request)) 5. Add a captcha template::
{% csrf_token %} {{ form.captcha.errors }} {{ form.captcha }}
django-axes-4.1.0/docs/conf.py0000644000175000017500000002223713242265213015131 0ustar jamesjames# -*- coding: utf-8 -*- # # Django Axes documentation build configuration file, created by # sphinx-quickstart on Sat Jul 30 16:37:41 2016. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import sys import os import shlex import sphinx_rtd_theme # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. #sys.path.insert(0, os.path.abspath('.')) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [] # 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 encoding of source files. #source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = 'Django Axes' copyright = '2016, jazzband' author = 'jazzband' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = '4.1.0' # The full version, including alpha/beta/rc tags. release = '4.1.0' # 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 # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['_build'] # The reST default role (used for this markup: `text`) to use for all # documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). #add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. #keep_warnings = False # 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' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. #html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". #html_title = None # A shorter title for the navigation bar. Default is the same as html_title. #html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. #html_favicon = None # 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'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. #html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. #html_sidebars = {} html_sidebars = { '**': ['globaltoc.html', 'relations.html', 'sourcelink.html', 'searchbox.html'], } # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. #html_domain_indices = True # If false, no index is generated. #html_use_index = True # If true, the index is split into individual pages for each letter. #html_split_index = False # If true, links to the reST sources are added to the pages. #html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. #html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. #html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' #html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # Now only 'ja' uses this config value #html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. #html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. htmlhelp_basename = 'DjangoAxesdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', # Additional stuff for the LaTeX preamble. #'preamble': '', # Latex figure (float) alignment #'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', 'Django Axes Documentation', 'jazzband', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. #latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # If true, show page references after internal links. #latex_show_pagerefs = False # If true, show URL addresses after external links. #latex_show_urls = False # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_domain_indices = True # -- 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', 'Django Axes Documentation', [author], 1) ] # If true, show URL addresses after external links. #man_show_urls = False # -- 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', 'Django Axes Documentation', author, 'DjangoAxes', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. #texinfo_appendices = [] # If false, no module index is generated. #texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. #texinfo_no_detailmenu = False django-axes-4.1.0/docs/installation.rst0000644000175000017500000000021713242265213017057 0ustar jamesjames.. _installation: Installation ============ You can install the latest stable package running this command:: $ pip install django-axes django-axes-4.1.0/README.rst0000644000175000017500000000233613242265213014367 0ustar jamesjamesDjango Axes =========== .. image:: https://jazzband.co/static/img/badge.svg :target: https://jazzband.co/ :alt: Jazzband .. image:: https://secure.travis-ci.org/jazzband/django-axes.svg?branch=master :target: http://travis-ci.org/jazzband/django-axes :alt: Build Status .. image:: https://coveralls.io/repos/github/jazzband/django-axes/badge.svg?branch=master :target: https://coveralls.io/github/jazzband/django-axes?branch=master :alt: Coveralls ``django-axes`` is a very simple way for you to keep track of failed login attempts, both for the Django admin and for the rest of your site. The name is sort of a geeky pun, since ``axes`` can be read interpreted as: * "access", as in monitoring access attempts * "axes", as in tools you can use hack (generally on wood). In this case, however, the "hacking" part of it can be taken a bit further: ``django-axes`` is intended to help you *stop* people from hacking (popular media definition) your website. Hilarious, right? That's what I thought too! For more information see the documentation at: https://django-axes.readthedocs.io/ If you have questions or have trouble using the app please file a bug report at: https://github.com/jazzband/django-axes/issues django-axes-4.1.0/tox.ini0000644000175000017500000000106113242265213014205 0ustar jamesjames[tox] envlist = py{27,34,35,36}-django-111 py{34,35,36}-django-20 py{35,36}-django-master [testenv] deps = py27: mock django-appconf django-ipware coveralls django-111: Django>=1.11,<2.0 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 django-axes-4.1.0/CONTRIBUTING.md0000644000175000017500000000046413242265213015131 0ustar jamesjames[![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines). django-axes-4.1.0/setup.py0000644000175000017500000000355513242265213014416 0ustar jamesjames#!/usr/bin/env python # -*- coding: utf-8 -*- import codecs from setuptools import setup, find_packages from axes import get_version setup( name='django-axes', version=get_version(), 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()), keywords='authentication django pci security'.split(), author='Josh VanderLinden, Philip Neustrom, Michael Blume, Camilo Nova', author_email='codekoala@gmail.com', maintainer='Alex Clark', maintainer_email='aclark@aclark.net', url='https://github.com/jazzband/django-axes', license='MIT', package_dir={'axes': 'axes'}, 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=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Framework :: Django', 'Framework :: Django :: 1.11', 'Framework :: Django :: 2.0', 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Topic :: Internet :: Log Analysis', 'Topic :: Security', 'Topic :: System :: Logging', ], zip_safe=False, ) django-axes-4.1.0/LICENSE0000644000175000017500000000214013242265213013676 0ustar jamesjamesThe MIT License Copyright (c) 2008 Josh VanderLinden, 2009 Philip Neustrom 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.