Repository: jazzband/django-axes

Issue:
AXES_LOGIN_FAILURE_LIMIT shouldn't be zero based
I set `AXES_LOGIN_FAILURE_LIMIT` to 3, and was confused when it always allowed 4 attempts before locking it down.

Diving into the code, it turns out failures starts on 0, and is incremented after the failure, which makes sense, except that the code is checking if failures is GREATER than AXES_LOGIN_FAILURE_LIMIT.



Discussion:
Thanks, can you send a pull request to fix this please?


Resolving patch:
diff --git a/axes/decorators.py b/axes/decorators.py
--- a/axes/decorators.py
+++ b/axes/decorators.py
@@ -333,7 +333,7 @@ def check_request(request, login_unsuccessful):
     user_lockable = is_user_lockable(request)
     # 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 >= FAILURE_LIMIT and LOCK_OUT_AT_FAILURE and user_lockable:
+    if failures > FAILURE_LIMIT and LOCK_OUT_AT_FAILURE and user_lockable:
         # We log them out in case they actually managed to enter the correct
         # password
         logout(request)


Test patch:
diff --git a/axes/tests.py b/axes/tests.py
--- a/axes/tests.py
+++ b/axes/tests.py
@@ -1,7 +1,3 @@
-# Only run tests if they have axes in middleware
-
-# Basically a functional test
-
 import random
 import string
 
@@ -20,12 +16,15 @@ class AccessAttemptTest(TestCase):
     LOCKED_MESSAGE = 'Account locked: too many login attempts.'
 
     def _generate_random_string(self):
-        """Generates a random string"""
+        """Generates a random string
+        """
         chars = string.ascii_uppercase + string.digits
+
         return ''.join(random.choice(chars) for x in range(20))
 
     def _random_username(self, existing_username=False):
-        """Returns a username, existing or not depending on params"""
+        """Returns a username, existing or not depending on params
+        """
         if existing_username:
             return User.objects.order_by('?')[0].username
 
@@ -40,6 +39,8 @@ def _login(self, existing_username=False):
         return response
 
     def setUp(self):
+        """Creates users for testing the login
+        """
         for i in range(0, random.randrange(10, 50)):
             username = "person%s" % i
             email = "%s@example.org" % username
@@ -52,7 +53,10 @@ def setUp(self):
             u.save()
 
     def test_login_max(self, existing_username=False):
-        for i in range(0, FAILURE_LIMIT - 1):
+        """Tests the login lock trying to login one more time
+        than failure limit
+        """
+        for i in range(0, FAILURE_LIMIT):
             response = self._login(existing_username=existing_username)
             # Check if we are in the same login page
             self.assertContains(response, LOGIN_FORM_KEY)
@@ -63,10 +67,15 @@ def test_login_max(self, existing_username=False):
         self.assertContains(response, self.LOCKED_MESSAGE)
 
     def test_with_real_username_max(self):
+        """Tests the login lock with a real username
+        """
         self.test_login_max(existing_username=True)
 
     def test_login_max_with_more_attempts(self, existing_username=False):
-        for i in range(0, FAILURE_LIMIT - 2):
+        """Tests the login lock trying to login a lot of times more
+        than failure limit
+        """
+        for i in range(0, FAILURE_LIMIT):
             response = self._login(existing_username=existing_username)
             # Check if we are in the same login page
             self.assertContains(response, LOGIN_FORM_KEY)
@@ -79,9 +88,13 @@ def test_login_max_with_more_attempts(self, existing_username=False):
         self.assertContains(response, self.LOCKED_MESSAGE)
 
     def test_with_real_username_max_with_more(self):
+        """Tests the login lock for a bunch of times with a real username
+        """
         self.test_login_max_with_more_attempts(existing_username=True)
 
     def test_valid_login(self):
+        """Tests a valid login for a real username
+        """
         valid_username = self._random_username(existing_username=True)
         response = self.client.post(reverse('admin:index'), {
             'username': valid_username,