diff --git a/axes/decorators.py b/axes/decorators.py index d568c3c..5eb95cd 100644 --- a/axes/decorators.py +++ b/axes/decorators.py @@ -1,5 +1,6 @@ import logging import socket +import json from datetime import timedelta @@ -30,6 +31,7 @@ from axes.models import AccessAttempt from axes.signals import user_locked_out import axes from django.utils import six +from axes.utils import iso8601 # see if the user has overridden the failure limit @@ -352,12 +354,19 @@ def watch_login(func): def lockout_response(request): + context = { + 'failure_limit': FAILURE_LIMIT, + 'username': request.POST.get(USERNAME_FORM_FIELD, '') + } + + if request.is_ajax(): + context.update({'cooloff_time': iso8601(COOLOFF_TIME)}) + return HttpResponse(json.dumps(context), + content_type='application/json', + status=403) + if LOCKOUT_TEMPLATE: - context = { - 'cooloff_time': COOLOFF_TIME, - 'failure_limit': FAILURE_LIMIT, - 'username': request.POST.get(USERNAME_FORM_FIELD, '') - } + context.update({'cooloff_time': COOLOFF_TIME}) return render(request, LOCKOUT_TEMPLATE, context, status=403) LOCKOUT_URL = get_lockout_url() diff --git a/axes/tests.py b/axes/tests.py index cf638b7..368daa5 100644 --- a/axes/tests.py +++ b/axes/tests.py @@ -1,6 +1,8 @@ import random import string import time +import json +import datetime from django.test import TestCase from django.test.utils import override_settings @@ -14,7 +16,7 @@ from axes.decorators import FAILURE_LIMIT from axes.decorators import is_valid_public_ip from axes.models import AccessAttempt, AccessLog from axes.signals import user_locked_out -from axes.utils import reset +from axes.utils import reset, iso8601 class AccessAttemptTest(TestCase): @@ -25,7 +27,8 @@ class AccessAttemptTest(TestCase): LOCKED_MESSAGE = 'Account locked: too many login attempts.' LOGIN_FORM_KEY = '' - def _login(self, is_valid_username=False, is_valid_password=False, user_agent='test-browser', **kwargs): + 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. """ @@ -47,13 +50,23 @@ class AccessAttemptTest(TestCase): 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) - response = self.client.post(admin_login, post_data, HTTP_USER_AGENT=user_agent) + + 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, **headers) return response @@ -217,6 +230,14 @@ class AccessAttemptTest(TestCase): 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') + class IPClassifierTest(TestCase): @@ -239,3 +260,27 @@ class IPClassifierTest(TestCase): } for ip_address, is_valid_public in six.iteritems(EXPECTED): self.assertEqual(is_valid_public_ip(ip_address), is_valid_public) + +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): + 'P2D1H42M8S', + datetime.timedelta(days=7, seconds=342): + 'P7D5M42S', + datetime.timedelta(days=0, hours=2, minutes=42): + 'P2H42M', + datetime.timedelta(hours=20, seconds=42): + 'P20H42S', + datetime.timedelta(seconds=300): + 'P5M', + datetime.timedelta(seconds=9005): + 'P2H30M5S', + datetime.timedelta(minutes=9005): + 'P6D6H5M' + } + for timedelta, iso_duration in six.iteritems(EXPECTED): + self.assertEqual(iso8601(timedelta), iso_duration) diff --git a/axes/utils.py b/axes/utils.py index 0ea87ea..cc88241 100644 --- a/axes/utils.py +++ b/axes/utils.py @@ -18,3 +18,24 @@ def reset(ip=None, username=None): attempts.delete() return count + + +def iso8601(value): + """Returns datetime.timedelta translated to ISO 8601 formatted duration. + """ + seconds = value.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 u'P' + date + time