diff --git a/axes/decorators.py b/axes/decorators.py index 310be51..0f95fe0 100644 --- a/axes/decorators.py +++ b/axes/decorators.py @@ -56,7 +56,8 @@ BEHIND_REVERSE_PROXY_WITH_DIRECT_ACCESS = getattr(settings, 'AXES_BEHIND_REVERSE REVERSE_PROXY_HEADER = getattr(settings, 'AXES_REVERSE_PROXY_HEADER', 'HTTP_X_FORWARDED_FOR') # lock out user from particular IP based on combination USER+IP -LOCK_OUT_BY_COMBINATION_USER_AND_IP = getattr(settings, 'AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP', False) +def should_lock_out_by_combination_user_and_ip(): + return getattr(settings, 'AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP', False) COOLOFF_TIME = getattr(settings, 'AXES_COOLOFF_TIME', None) if (isinstance(COOLOFF_TIME, int) or isinstance(COOLOFF_TIME, float) ): @@ -238,10 +239,12 @@ def _get_user_attempts(request): ip_address=ip, username=username, trusted=True ) - if not attempts and not LOCK_OUT_BY_COMBINATION_USER_AND_IP: + if not attempts: params = {'ip_address': ip, 'trusted': False} if USE_USER_AGENT: params['user_agent'] = ua + if should_lock_out_by_combination_user_and_ip(): + params['username'] = username attempts = AccessAttempt.objects.filter(**params) diff --git a/axes/tests.py b/axes/tests.py index d687b3b..04b0381 100644 --- a/axes/tests.py +++ b/axes/tests.py @@ -3,6 +3,7 @@ import string import time from django.test import TestCase +from django.test.utils import override_settings from django.contrib.auth.models import User from django.core.urlresolvers import NoReverseMatch from django.core.urlresolvers import reverse @@ -17,12 +18,13 @@ from axes.utils import reset 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=False, user_agent='test-browser'): - """Login a user. A valid credential is used when is_valid is True, + def _login(self, is_valid_username=False, is_valid_password=False, user_agent='test-browser'): + """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. """ try: @@ -30,17 +32,22 @@ class AccessAttemptTest(TestCase): except NoReverseMatch: admin_login = reverse('admin:index') - if is_valid: + if is_valid_username: # Use a valid username - username = self.user.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' + response = self.client.post(admin_login, { 'username': username, - 'password': self.VALID_PASSWORD, + 'password': password, 'this_is_the_login_form': 1, }, HTTP_USER_AGENT=user_agent) @@ -50,7 +57,7 @@ class AccessAttemptTest(TestCase): """Create a valid user for login """ self.user = User.objects.create_superuser( - username='valid-username', + username=self.VALID_USERNAME, email='test@example.com', password=self.VALID_PASSWORD, ) @@ -87,13 +94,13 @@ class AccessAttemptTest(TestCase): def test_valid_login(self): """Tests a valid login for a real username """ - response = self._login(is_valid=True) + 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=True) + 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')) @@ -124,7 +131,7 @@ class AccessAttemptTest(TestCase): """Tests if can handle a long user agent """ long_user_agent = 'ie6' * 1024 - response = self._login(is_valid=True, user_agent=long_user_agent) + 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): @@ -183,3 +190,18 @@ class AccessAttemptTest(TestCase): # 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 + """ + for i in range(1, FAILURE_LIMIT): # test until one try before the 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)