Repository: jazzband/django-axes

Issue:
Not blocking user only attempts.
User Only Failures is not blocking the 4th and later login attempts.
After 3 attempts the user is redirected to the lockout_template once, BUT....
If you reload the login page, UNLIMITED requests for the same username are allowed.
I can even provide the correct login credentials and authenticate the user during this "cool off time".

Commenting out the ONLY_USER_FAILURES and reloading the server works as expected when blocking by IP.
``` python
AXES_ONLY_USER_FAILURES = True
AXES_LOGIN_FAILURE_LIMIT = 3
AXES_USERNAME_FORM_FIELD = 'username'
AXES_LOCK_OUT_AT_FAILURE = True
# AXES_COOLOFF_TIME = 1  # one hour (Integer values define only hours in AXES)
AXES_COOLOFF_TIME = timedelta(minutes=15)  # 15 minutes (as timedelta object for minutes)
AXES_LOCKOUT_TEMPLATE = 'registration/_locked.html'
```

Python 2.7.12
Django==1.10.6
django-axes==2.3.2
Not blocking user only attempts.
User Only Failures is not blocking the 4th and later login attempts.
After 3 attempts the user is redirected to the lockout_template once, BUT....
If you reload the login page, UNLIMITED requests for the same username are allowed.
I can even provide the correct login credentials and authenticate the user during this "cool off time".

Commenting out the ONLY_USER_FAILURES and reloading the server works as expected when blocking by IP.
``` python
AXES_ONLY_USER_FAILURES = True
AXES_LOGIN_FAILURE_LIMIT = 3
AXES_USERNAME_FORM_FIELD = 'username'
AXES_LOCK_OUT_AT_FAILURE = True
# AXES_COOLOFF_TIME = 1  # one hour (Integer values define only hours in AXES)
AXES_COOLOFF_TIME = timedelta(minutes=15)  # 15 minutes (as timedelta object for minutes)
AXES_LOCKOUT_TEMPLATE = 'registration/_locked.html'
```

Python 2.7.12
Django==1.10.6
django-axes==2.3.2


Discussion:
Seems this is actually by design. According the associated unit test:
```
# now create failure_limit + 1 failed logins and then we should still be able to login with valid_username
for i in range(1, 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)
```

This seems to be rather counter-intuitive. I would have thought the point of locking out when login attempts fail is to lock the user account.
Seems this is actually by design. According the associated unit test:
```
# now create failure_limit + 1 failed logins and then we should still be able to login with valid_username
for i in range(1, 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)
```

This seems to be rather counter-intuitive. I would have thought the point of locking out when login attempts fail is to lock the user account.

Resolving patch:
diff --git a/axes/decorators.py b/axes/decorators.py
--- a/axes/decorators.py
+++ b/axes/decorators.py
@@ -171,11 +171,18 @@ def _get_user_attempts(request):
         )
 
     if not attempts:
-        params = {'ip_address': ip, 'trusted': False}
+        params = {'trusted': False}
+
+        if AXES_ONLY_USER_FAILURES:
+            params['username'] = username
+        elif LOCK_OUT_BY_COMBINATION_USER_AND_IP:
+            params['username'] = username
+            params['ip_address'] = ip
+        else:
+            params['ip_address'] = ip
+
         if USE_USER_AGENT:
             params['user_agent'] = ua
-        if LOCK_OUT_BY_COMBINATION_USER_AND_IP:
-            params['username'] = username
 
         attempts = AccessAttempt.objects.filter(**params)
 
@@ -492,23 +499,30 @@ def get_cache_key(request_or_object):
     :param  request_or_object: Request or AccessAttempt object
     :return cache-key: String, key to be used in cache system
     """
-    ua = None
-    ip = None
-
     if isinstance(request_or_object, AccessAttempt):
         ip = request_or_object.ip_address
+        un = request_or_object.username
         ua = request_or_object.user_agent
     else:
         ip = get_ip(request_or_object)
+        un = request_or_object.POST.get(USERNAME_FORM_FIELD, None)
         ua = request_or_object.META.get('HTTP_USER_AGENT', '<unknown>')[:255]
 
-    ip = ip.encode('utf-8')
+    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 ua:
-        ua = ua.encode('utf-8')
-        cache_hash_key = 'axes-{}'.format(md5(ip+ua).hexdigest())
+    if AXES_ONLY_USER_FAILURES:
+        attributes = un
+    elif LOCK_OUT_BY_COMBINATION_USER_AND_IP:
+        attributes = ip+un
     else:
-        cache_hash_key = 'axes-{}'.format(md5(ip).hexdigest())
+        attributes = ip
+
+    if USE_USER_AGENT:
+        attributes += ua
+
+    cache_hash_key = 'axes-{}'.format(md5(attributes).hexdigest())
 
     return cache_hash_key
 
diff --git a/runtests.py b/runtests.py
--- a/runtests.py
+++ b/runtests.py
@@ -20,5 +20,6 @@ def run_tests(settings_module, *modules):
 if __name__ == '__main__':
     run_tests('axes.test_settings', [
         'axes.tests.AccessAttemptTest',
+        'axes.tests.AccessAttemptConfigTest',
         'axes.tests.UtilsTest',
     ])


Test patch:
diff --git a/axes/test_settings.py b/axes/test_settings.py
--- a/axes/test_settings.py
+++ b/axes/test_settings.py
@@ -1,5 +1,3 @@
-from datetime import timedelta
-
 DATABASES = {
     'default': {
         'ENGINE': 'django.db.backends.sqlite3',
@@ -55,4 +53,3 @@
 LOGIN_REDIRECT_URL = '/admin/'
 
 AXES_LOGIN_FAILURE_LIMIT = 10
-AXES_COOLOFF_TIME = timedelta(seconds=2)
diff --git a/axes/tests.py b/axes/tests.py
--- a/axes/tests.py
+++ b/axes/tests.py
@@ -9,7 +9,6 @@
 
 from django.conf import settings
 from django.test import TestCase
-from django.test.utils import override_settings
 from django.contrib.auth.models import User
 from django.core.urlresolvers import NoReverseMatch
 from django.core.urlresolvers import reverse
@@ -17,13 +16,15 @@
 from django.test.client import RequestFactory
 
 from axes.decorators import get_ip, get_cache_key
-from axes.settings import COOLOFF_TIME
 from axes.settings import FAILURE_LIMIT
 from axes.models import AccessAttempt, AccessLog
 from axes.signals import user_locked_out
 from axes.utils import reset, iso8601
 
 
+TEST_COOLOFF_TIME = datetime.timedelta(seconds=2)
+
+
 class MockRequest:
     def __init__(self):
         self.META = dict()
@@ -141,17 +142,19 @@ def test_valid_logout(self, cache_set_mock, cache_get_mock):
         self.assertNotEquals(AccessLog.objects.latest('id').logout_time, None)
         self.assertContains(response, 'Logged out')
 
+    @patch('axes.decorators.COOLOFF_TIME', TEST_COOLOFF_TIME)
     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(COOLOFF_TIME.total_seconds())
+        time.sleep(TEST_COOLOFF_TIME.total_seconds())
 
         # It should be possible to login again, make sure it is.
         self.test_valid_login()
 
+    @patch('axes.decorators.COOLOFF_TIME', TEST_COOLOFF_TIME)
     def test_cooling_off_for_trusted_user(self):
         """Test the cooling time for a trusted user
         """
@@ -214,7 +217,7 @@ def test_get_cache_key(self, get_ip_mock):
         ip = '127.0.0.1'.encode('utf-8')
         ua = '<unknown>'.encode('utf-8')
 
-        cache_hash_key_checker = 'axes-{}'.format(md5((ip+ua)).hexdigest())
+        cache_hash_key_checker = 'axes-{}'.format(md5((ip)).hexdigest())
 
         request_factory = RequestFactory()
         request = request_factory.post('/admin/login/',
@@ -286,7 +289,7 @@ def test_lockout_by_combination_user_and_ip(self, cache_set_mock,
         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)
+    @patch('axes.decorators.AXES_ONLY_USER_FAILURES', True)
     @patch('axes.decorators.cache.set', return_value=None)
     @patch('axes.decorators.cache.get', return_value=None)
     def test_lockout_by_user_only(self, cache_set_mock, cache_get_mock):
@@ -444,6 +447,469 @@ def test_check_is_not_made_on_GET(self):
         self.assertEqual(response.status_code, 200)
 
 
+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 = '<input type="submit" value="Log in" />'
+    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.
+        """
+        try:
+            admin_login = reverse('admin:login')
+        except NoReverseMatch:
+            admin_login = reverse('admin:index')
+
+        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(
+            admin_login, post_data, REMOTE_ADDR=ip_addr, **headers
+        )
+        return response
+
+    def _lockout_user1_from_ip1(self):
+        for i in range(1, FAILURE_LIMIT+1):
+            response = self._login(
+                username=self.USER_1,
+                password=self.WRONG_PASSWORD,
+                ip_addr=self.IP_1
+            )
+        return response
+
+    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.
+    @patch('axes.decorators.cache.set', return_value=None)
+    @patch('axes.decorators.cache.get', return_value=None)
+    def test_lockout_by_ip_blocks_when_same_user_same_ip_without_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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)
+
+    @patch('axes.decorators.cache.set', return_value=None)
+    @patch('axes.decorators.cache.get', return_value=None)
+    def test_lockout_by_ip_allows_when_same_user_diff_ip_without_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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)
+
+    @patch('axes.decorators.cache.set', return_value=None)
+    @patch('axes.decorators.cache.get', return_value=None)
+    def test_lockout_by_ip_blocks_when_diff_user_same_ip_without_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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)
+
+    @patch('axes.decorators.cache.set', return_value=None)
+    @patch('axes.decorators.cache.get', return_value=None)
+    def test_lockout_by_ip_allows_when_diff_user_diff_ip_without_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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
+    @patch('axes.decorators.AXES_ONLY_USER_FAILURES', True)
+    @patch('axes.decorators.cache.set', return_value=None)
+    @patch('axes.decorators.cache.get', return_value=None)
+    def test_lockout_by_user_blocks_when_same_user_same_ip_without_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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)
+
+    @patch('axes.decorators.AXES_ONLY_USER_FAILURES', True)
+    @patch('axes.decorators.cache.set', return_value=None)
+    @patch('axes.decorators.cache.get', return_value=None)
+    def test_lockout_by_user_blocks_when_same_user_diff_ip_without_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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)
+
+    @patch('axes.decorators.AXES_ONLY_USER_FAILURES', True)
+    @patch('axes.decorators.cache.set', return_value=None)
+    @patch('axes.decorators.cache.get', return_value=None)
+    def test_lockout_by_user_allows_when_diff_user_same_ip_without_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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)
+
+    @patch('axes.decorators.AXES_ONLY_USER_FAILURES', True)
+    @patch('axes.decorators.cache.set', return_value=None)
+    @patch('axes.decorators.cache.get', return_value=None)
+    def test_lockout_by_user_allows_when_diff_user_diff_ip_without_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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.
+    # Cache disabled. When LOCK_OUT_BY_COMBINATION_USER_AND_IP = True
+    @patch('axes.decorators.LOCK_OUT_BY_COMBINATION_USER_AND_IP', True)
+    @patch('axes.decorators.cache.set', return_value=None)
+    @patch('axes.decorators.cache.get', return_value=None)
+    def test_lockout_by_user_and_ip_blocks_when_same_user_same_ip_without_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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)
+
+    @patch('axes.decorators.LOCK_OUT_BY_COMBINATION_USER_AND_IP', True)
+    @patch('axes.decorators.cache.set', return_value=None)
+    @patch('axes.decorators.cache.get', return_value=None)
+    def test_lockout_by_user_and_ip_allows_when_same_user_diff_ip_without_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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)
+
+    @patch('axes.decorators.LOCK_OUT_BY_COMBINATION_USER_AND_IP', True)
+    @patch('axes.decorators.cache.set', return_value=None)
+    @patch('axes.decorators.cache.get', return_value=None)
+    def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_without_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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)
+
+    @patch('axes.decorators.LOCK_OUT_BY_COMBINATION_USER_AND_IP', True)
+    @patch('axes.decorators.cache.set', return_value=None)
+    @patch('axes.decorators.cache.get', return_value=None)
+    def test_lockout_by_user_and_ip_allows_when_diff_user_diff_ip_without_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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 IP *OR* user (default).
+    # With cache enabled. Default criteria.
+    def test_lockout_by_ip_blocks_when_same_user_same_ip_using_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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.
+    # With cache enabled. When AXES_ONLY_USER_FAILURES = True
+    @patch('axes.decorators.AXES_ONLY_USER_FAILURES', True)
+    def test_lockout_by_user_blocks_when_same_user_same_ip_using_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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)
+
+    @patch('axes.decorators.AXES_ONLY_USER_FAILURES', True)
+    def test_lockout_by_user_blocks_when_same_user_diff_ip_using_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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)
+
+    @patch('axes.decorators.AXES_ONLY_USER_FAILURES', True)
+    def test_lockout_by_user_allows_when_diff_user_same_ip_using_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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)
+
+    @patch('axes.decorators.AXES_ONLY_USER_FAILURES', True)
+    def test_lockout_by_user_allows_when_diff_user_diff_ip_using_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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
+    @patch('axes.decorators.LOCK_OUT_BY_COMBINATION_USER_AND_IP', True)
+    def test_lockout_by_user_and_ip_blocks_when_same_user_same_ip_using_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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)
+
+    @patch('axes.decorators.LOCK_OUT_BY_COMBINATION_USER_AND_IP', True)
+    def test_lockout_by_user_and_ip_allows_when_same_user_diff_ip_using_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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)
+
+    @patch('axes.decorators.LOCK_OUT_BY_COMBINATION_USER_AND_IP', True)
+    def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_using_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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)
+
+    @patch('axes.decorators.LOCK_OUT_BY_COMBINATION_USER_AND_IP', True)
+    def test_lockout_by_user_and_ip_allows_when_diff_user_diff_ip_using_cache(
+        self, cache_get_mock=None, cache_set_mock=None
+    ):
+        # 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)
+
+
 class UtilsTest(TestCase):
     def test_iso8601(self):
         """Tests iso8601 correctly translates datetime.timdelta to ISO 8601