From 55e83bd629ad90a63894d0396413c3f1ac41f721 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Tue, 25 Apr 2017 11:39:19 -0700 Subject: [PATCH 1/8] Log messages based on config settings --- axes/decorators.py | 46 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/axes/decorators.py b/axes/decorators.py index e26df06..f987764 100644 --- a/axes/decorators.py +++ b/axes/decorators.py @@ -23,6 +23,12 @@ log = logging.getLogger(LOGGER) if VERBOSE: log.info('AXES: BEGIN LOG') log.info('AXES: Using django-axes ' + axes.get_version()) + if AXES_ONLY_USER_FAILURES: + log.info('AXES: blocking by username only.') + elif 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.') if BEHIND_REVERSE_PROXY: @@ -388,10 +394,21 @@ def check_request(request, login_unsuccessful): attempt.failures_since_start = failures attempt.attempt_time = datetime.now() attempt.save() - log.info( - 'AXES: Repeated login failure by %s. Updating access ' - 'record. Count = %s' % (attempt.ip_address, failures) + + if AXES_ONLY_USER_FAILURES: + block_msg = 'for {0}.'.format(attempt.username) + elif LOCK_OUT_BY_COMBINATION_USER_AND_IP: + block_msg = 'for {0} from {1}.'.format( + attempt.username, attempt.ip_address + ) + else: + block_msg = 'from {0}.'.format(attempt.ip_address) + count_msg = 'Updating access record. Count = {0} of {1}'.format( + failures, FAILURE_LIMIT ) + log.info('AXES: Repeated login failure {0} {1}'.format( + block_msg, count_msg + )) else: create_new_failure_records(request, failures) else: @@ -426,9 +443,16 @@ def check_request(request, login_unsuccessful): # password if hasattr(request, 'user') and request.user.is_authenticated(): logout(request) - log.warn( - 'AXES: locked out %s after repeated login attempts.' % ip_address - ) + + msg = 'AXES: locked out {0} after repeated login attempts.' + username = request.POST.get(USERNAME_FORM_FIELD, None) + if AXES_ONLY_USER_FAILURES: + log.warn(msg.format(username)) + elif LOCK_OUT_BY_COMBINATION_USER_AND_IP: + log.warn(msg.format('{0} from {1}'.format(username, ip_address))) + else: + log.warn(msg.format(ip_address)) + # send signal when someone is locked out. user_locked_out.send( 'axes', request=request, username=username, ip_address=ip_address @@ -462,7 +486,15 @@ def create_new_failure_records(request, failures): path_info=request.META.get('PATH_INFO', ''), failures_since_start=failures, ) - log.info('AXES: New login failure by %s. Creating access record.' % (ip,)) + + msg = 'AXES: New login failure by {0}. Creating access record.' + username = request.POST.get(USERNAME_FORM_FIELD, None) + if AXES_ONLY_USER_FAILURES: + log.info(msg.format(username)) + elif LOCK_OUT_BY_COMBINATION_USER_AND_IP: + log.info(msg.format('{0} from {1}'.format(username, ip))) + else: + log.info(msg.format(ip)) def create_new_trusted_record(request): From 4d4b1d233f53b4584c36a0a52eb997c6a42a27b6 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Tue, 25 Apr 2017 12:42:22 -0700 Subject: [PATCH 2/8] Factored out logging into functions --- axes/decorators.py | 74 +++++++++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/axes/decorators.py b/axes/decorators.py index f987764..3f8ef80 100644 --- a/axes/decorators.py +++ b/axes/decorators.py @@ -42,6 +42,42 @@ if BEHIND_REVERSE_PROXY: ) +def get_client_str(username=None, ip_address=None): + if AXES_ONLY_USER_FAILURES: + return username + elif LOCK_OUT_BY_COMBINATION_USER_AND_IP: + return '{0} from {1}'.format(username, ip_address) + else: + return ip_address + + +def log_initial_attempt(username, ip_address): + client = get_client_str(username, ip_address) + msg = 'AXES: New login failure by {0}. Creating access record.' + log.info(msg.format(client)) + + +def log_repeated_attempt(username, ip_address, fail_count): + client = get_client_str(username, ip_address) + fail_msg = 'AXES: Repeated login failure by {0}. Updating access record.' + count_msg = 'Count = {0} of {1}'.format(fail_count, FAILURE_LIMIT) + log.info('{0} {1}'.format(fail_msg.format(client), count_msg)) + + +def log_lockout(username, ip_address): + client = get_client_str(username, ip_address) + msg = 'AXES: locked out {0} after repeated login attempts.' + log.warn(msg.format(client)) + + +def log_decorated_call(func, args=None, kwargs=None): + log.info('AXES: Calling decorated function: %s' % func.__name__) + if args: + log.info('args: %s' % str(args)) + if kwargs: + log.info('kwargs: %s' % kwargs) + + def is_ipv6(ip): try: inet_pton(AF_INET6, ip) @@ -230,11 +266,7 @@ def watch_login(func): def decorated_login(request, *args, **kwargs): # share some useful information if func.__name__ != 'decorated_login' and VERBOSE: - log.info('AXES: Calling decorated function: %s' % func.__name__) - if args: - log.info('args: %s' % str(args)) - if kwargs: - log.info('kwargs: %s' % kwargs) + log_decorated_call(func, args, kwargs) # TODO: create a class to hold the attempts records and perform checks # with its methods? or just store attempts=get_user_attempts here and @@ -395,20 +427,8 @@ def check_request(request, login_unsuccessful): attempt.attempt_time = datetime.now() attempt.save() - if AXES_ONLY_USER_FAILURES: - block_msg = 'for {0}.'.format(attempt.username) - elif LOCK_OUT_BY_COMBINATION_USER_AND_IP: - block_msg = 'for {0} from {1}.'.format( - attempt.username, attempt.ip_address - ) - else: - block_msg = 'from {0}.'.format(attempt.ip_address) - count_msg = 'Updating access record. Count = {0} of {1}'.format( - failures, FAILURE_LIMIT - ) - log.info('AXES: Repeated login failure {0} {1}'.format( - block_msg, count_msg - )) + log_repeated_attempt(username, ip_address, failures) + else: create_new_failure_records(request, failures) else: @@ -444,14 +464,8 @@ def check_request(request, login_unsuccessful): if hasattr(request, 'user') and request.user.is_authenticated(): logout(request) - msg = 'AXES: locked out {0} after repeated login attempts.' username = request.POST.get(USERNAME_FORM_FIELD, None) - if AXES_ONLY_USER_FAILURES: - log.warn(msg.format(username)) - elif LOCK_OUT_BY_COMBINATION_USER_AND_IP: - log.warn(msg.format('{0} from {1}'.format(username, ip_address))) - else: - log.warn(msg.format(ip_address)) + log_lockout(username, ip_address) # send signal when someone is locked out. user_locked_out.send( @@ -487,14 +501,8 @@ def create_new_failure_records(request, failures): failures_since_start=failures, ) - msg = 'AXES: New login failure by {0}. Creating access record.' username = request.POST.get(USERNAME_FORM_FIELD, None) - if AXES_ONLY_USER_FAILURES: - log.info(msg.format(username)) - elif LOCK_OUT_BY_COMBINATION_USER_AND_IP: - log.info(msg.format('{0} from {1}'.format(username, ip))) - else: - log.info(msg.format(ip)) + log_initial_attempt(username, ip) def create_new_trusted_record(request): From 765fddb64a21ae853e05f029f2e22ee89a692562 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Tue, 25 Apr 2017 13:49:43 -0700 Subject: [PATCH 3/8] Log successful auth if configured When AXES_DISABLE_SUCCESS_ACCESS_LOG=False, write a log that successful authentication has happened, along with client info. --- axes/decorators.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/axes/decorators.py b/axes/decorators.py index 3f8ef80..6b981f3 100644 --- a/axes/decorators.py +++ b/axes/decorators.py @@ -51,6 +51,12 @@ def get_client_str(username=None, ip_address=None): return ip_address +def log_successful_attempt(username, ip_address): + client = get_client_str(username, ip_address) + msg = 'AXES: Successful login by {0}. Creating access record.' + log.info(msg.format(client)) + + def log_initial_attempt(username, ip_address): client = get_client_str(username, ip_address) msg = 'AXES: New login failure by {0}. Creating access record.' @@ -305,15 +311,21 @@ def watch_login(func): http_accept = request.META.get('HTTP_ACCEPT', '')[:1025] path_info = request.META.get('PATH_INFO', '')[:255] if not DISABLE_ACCESS_LOG: + username = request.POST.get(USERNAME_FORM_FIELD, None) + ip_address = get_ip(request) + if login_unsuccessful or not DISABLE_SUCCESS_ACCESS_LOG: AccessLog.objects.create( user_agent=user_agent, - ip_address=get_ip(request), - username=request.POST.get(USERNAME_FORM_FIELD, None), + ip_address=ip_address, + username=username, http_accept=http_accept, path_info=path_info, trusted=not login_unsuccessful, ) + if not login_unsuccessful and not DISABLE_SUCCESS_ACCESS_LOG: + log_successful_attempt(username, ip_address) + if check_request(request, login_unsuccessful): return response From ebf9ca89ee083eebf692c1dbfbff5d588b6abe05 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Tue, 25 Apr 2017 14:47:33 -0700 Subject: [PATCH 4/8] Added user agent and verbose logging. --- axes/decorators.py | 54 ++++++++++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/axes/decorators.py b/axes/decorators.py index 6b981f3..bae0567 100644 --- a/axes/decorators.py +++ b/axes/decorators.py @@ -42,36 +42,48 @@ if BEHIND_REVERSE_PROXY: ) -def get_client_str(username=None, ip_address=None): +def get_client_str(username, ip_address, user_agent=None, path_info=None): + + if VERBOSE: + details = "{{user: '{0}', ip: '{1}', user-agent: '{2}', path: '{3}'}}" + return details.format(username, ip_address, user_agent, path_info) + if AXES_ONLY_USER_FAILURES: - return username + client = username elif LOCK_OUT_BY_COMBINATION_USER_AND_IP: - return '{0} from {1}'.format(username, ip_address) + client = '{0} from {1}'.format(username, ip_address) else: - return ip_address + client = ip_address + + if USE_USER_AGENT: + return client + '(user-agent={0})'.format(user_agent) + + return client -def log_successful_attempt(username, ip_address): - client = get_client_str(username, ip_address) +def log_successful_attempt(username, ip_address, + user_agent=None, path_info=None): + client = get_client_str(username, ip_address, user_agent, path_info) msg = 'AXES: Successful login by {0}. Creating access record.' log.info(msg.format(client)) -def log_initial_attempt(username, ip_address): - client = get_client_str(username, ip_address) +def log_initial_attempt(username, ip_address, user_agent, path_info): + client = get_client_str(username, ip_address, user_agent, path_info) msg = 'AXES: New login failure by {0}. Creating access record.' log.info(msg.format(client)) -def log_repeated_attempt(username, ip_address, fail_count): - client = get_client_str(username, ip_address) +def log_repeated_attempt(username, ip_address, user_agent, path_info, + fail_count): + client = get_client_str(username, ip_address, user_agent, path_info) fail_msg = 'AXES: Repeated login failure by {0}. Updating access record.' count_msg = 'Count = {0} of {1}'.format(fail_count, FAILURE_LIMIT) log.info('{0} {1}'.format(fail_msg.format(client), count_msg)) -def log_lockout(username, ip_address): - client = get_client_str(username, ip_address) +def log_lockout(username, ip_address, user_agent, path_info): + client = get_client_str(username, ip_address, user_agent, path_info) msg = 'AXES: locked out {0} after repeated login attempts.' log.warn(msg.format(client)) @@ -324,7 +336,8 @@ def watch_login(func): trusted=not login_unsuccessful, ) if not login_unsuccessful and not DISABLE_SUCCESS_ACCESS_LOG: - log_successful_attempt(username, ip_address) + log_successful_attempt(username, ip_address, + user_agent, path_info) if check_request(request, login_unsuccessful): return response @@ -402,6 +415,8 @@ def is_already_locked(request): def check_request(request, login_unsuccessful): ip_address = get_ip(request) username = request.POST.get(USERNAME_FORM_FIELD, None) + user_agent = request.META.get('HTTP_USER_AGENT', '')[:255] + path_info = request.META.get('PATH_INFO', '')[:255] failures = 0 attempts = get_user_attempts(request) cache_hash_key = get_cache_key(request) @@ -433,13 +448,13 @@ def check_request(request, login_unsuccessful): ) attempt.http_accept = \ request.META.get('HTTP_ACCEPT', '')[:1025] - attempt.path_info = \ - request.META.get('PATH_INFO', '')[:255] + attempt.path_info = path_info,path_info attempt.failures_since_start = failures attempt.attempt_time = datetime.now() attempt.save() - log_repeated_attempt(username, ip_address, failures) + log_repeated_attempt(username, ip_address, + user_agent, path_info, failures) else: create_new_failure_records(request, failures) @@ -477,7 +492,7 @@ def check_request(request, login_unsuccessful): logout(request) username = request.POST.get(USERNAME_FORM_FIELD, None) - log_lockout(username, ip_address) + log_lockout(username, ip_address, user_agent, path_info) # send signal when someone is locked out. user_locked_out.send( @@ -498,6 +513,7 @@ def create_new_failure_records(request, failures): ip = get_ip(request) ua = request.META.get('HTTP_USER_AGENT', '')[:255] username = request.POST.get(USERNAME_FORM_FIELD, None) + path_info = request.META.get('PATH_INFO', ''), # Record failed attempt. Whether or not the IP address or user agent is # used in counting failures is handled elsewhere, so we just record @@ -509,12 +525,12 @@ def create_new_failure_records(request, failures): get_data=query2str(request.GET), post_data=query2str(request.POST), http_accept=request.META.get('HTTP_ACCEPT', ''), - path_info=request.META.get('PATH_INFO', ''), + path_info=path_info, failures_since_start=failures, ) username = request.POST.get(USERNAME_FORM_FIELD, None) - log_initial_attempt(username, ip) + log_initial_attempt(username, ip, ua, path_info) def create_new_trusted_record(request): From 082c6ac35d73545b7a1e94e25bd2b65dccc2f600 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Tue, 25 Apr 2017 15:21:41 -0700 Subject: [PATCH 5/8] Boosting code coverage. --- axes/tests.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/axes/tests.py b/axes/tests.py index 1c32c6d..387615f 100644 --- a/axes/tests.py +++ b/axes/tests.py @@ -9,14 +9,13 @@ from mock import patch from django.conf import settings from django.test import TestCase -from django.test.utils import override_settings from django.contrib.auth.models import User from django.core.urlresolvers import NoReverseMatch from django.core.urlresolvers import reverse from django.utils import six from django.test.client import RequestFactory -from axes.decorators import get_ip, get_cache_key +from axes.decorators import get_ip, get_cache_key, get_client_str from axes.settings import COOLOFF_TIME from axes.settings import FAILURE_LIMIT from axes.models import AccessAttempt, AccessLog @@ -286,7 +285,7 @@ class AccessAttemptTest(TestCase): 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): @@ -475,6 +474,31 @@ class UtilsTest(TestCase): self.assertFalse(is_ipv6('67.255.125.204')) self.assertFalse(is_ipv6('foo')) + @patch('axes.decorators.VERBOSE', True) + def test_verbose_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) + + @patch('axes.decorators.VERBOSE', False) + def test_non_verbose_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) + class GetIPProxyTest(TestCase): """Test get_ip returns correct addresses with proxy From 98b82dd27db91a9ce1643de10d67490f7bbecf57 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Wed, 26 Apr 2017 09:37:11 -0700 Subject: [PATCH 6/8] Fixed path_info formatting. --- axes/decorators.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/axes/decorators.py b/axes/decorators.py index bae0567..5fe2877 100644 --- a/axes/decorators.py +++ b/axes/decorators.py @@ -45,6 +45,8 @@ if BEHIND_REVERSE_PROXY: def get_client_str(username, ip_address, user_agent=None, path_info=None): if 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) From 3b4f8fb7b31cea0c636238434097c6aa91647e97 Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Wed, 26 Apr 2017 14:17:24 -0700 Subject: [PATCH 7/8] Handles successful AJAX logins. --- axes/decorators.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/axes/decorators.py b/axes/decorators.py index 5fe2877..bb47feb 100644 --- a/axes/decorators.py +++ b/axes/decorators.py @@ -274,6 +274,22 @@ def get_user_attempts(request): return attempts +def is_login_failed(response): + return ( + response and + not response.has_header('location') and + response.status_code != 302 + ) + +def is_ajax_login_failed(response): + return ( + response and + response.status_code != 302 and + response.status_code != 200 + ) + + + def watch_login(func): """ Used to decorate the django.contrib.admin.site.login method. @@ -315,11 +331,10 @@ def watch_login(func): if request.method == 'POST': # see if the login was successful - login_unsuccessful = ( - response and - not response.has_header('location') and - response.status_code != 302 - ) + if request.is_ajax(): + login_unsuccessful = is_ajax_login_failed(response) + else: + login_unsuccessful = is_login_failed(response) user_agent = request.META.get('HTTP_USER_AGENT', '')[:255] http_accept = request.META.get('HTTP_ACCEPT', '')[:1025] From 4711fb88fffdfa40d381a57f44dca1f15633922b Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Sat, 13 May 2017 13:24:23 -0700 Subject: [PATCH 8/8] Boosting code coverage --- axes/tests.py | 90 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 5 deletions(-) diff --git a/axes/tests.py b/axes/tests.py index 16e86ec..b9de7ba 100644 --- a/axes/tests.py +++ b/axes/tests.py @@ -215,7 +215,6 @@ class AccessAttemptTest(TestCase): """ Test the cache key format""" # Getting cache key from request ip = '127.0.0.1'.encode('utf-8') - ua = ''.encode('utf-8') cache_hash_key_checker = 'axes-{}'.format(md5((ip)).hexdigest()) @@ -329,7 +328,6 @@ class AccessAttemptTest(TestCase): response = self._login(is_valid_username=True, is_valid_password=True) self.assertNotContains(response, self.LOGIN_FORM_KEY, status_code=302) - @patch('axes.decorators.cache.set', return_value=None) @patch('axes.decorators.cache.get', return_value=None) def test_log_data_truncated(self, cache_set_mock, cache_get_mock): @@ -942,20 +940,20 @@ class UtilsTest(TestCase): self.assertFalse(is_ipv6('foo')) @patch('axes.decorators.VERBOSE', True) - def test_verbose_client_details(self): + 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}'}}" + 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) @patch('axes.decorators.VERBOSE', False) - def test_non_verbose_client_details(self): + 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)' @@ -966,6 +964,87 @@ class UtilsTest(TestCase): self.assertEqual(expected, actual) + @patch('axes.decorators.AXES_ONLY_USER_FAILURES', True) + @patch('axes.decorators.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) + + @patch('axes.decorators.AXES_ONLY_USER_FAILURES', True) + @patch('axes.decorators.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) + + @patch('axes.decorators.LOCK_OUT_BY_COMBINATION_USER_AND_IP', True) + @patch('axes.decorators.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) + + @patch('axes.decorators.LOCK_OUT_BY_COMBINATION_USER_AND_IP', True) + @patch('axes.decorators.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) + + @patch('axes.decorators.USE_USER_AGENT', True) + @patch('axes.decorators.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) + + @patch('axes.decorators.USE_USER_AGENT', True) + @patch('axes.decorators.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) + class GetIPProxyTest(TestCase): """Test get_ip returns correct addresses with proxy @@ -1030,6 +1109,7 @@ class GetIPProxyCustomHeaderTest(TestCase): self.request.META[settings.AXES_REVERSE_PROXY_HEADER] = header self.assertEqual(self.ip, get_ip(self.request)) + class GetIPNumProxiesTest(TestCase): """Test that get_ip returns the correct last IP when NUM_PROXIES is configured """