diff --git a/README.md b/README.md index 39d2c75..6191414 100644 --- a/README.md +++ b/README.md @@ -131,3 +131,13 @@ ELSE EXEC END ``` + +Running Tests +============= + +Tests can be run, after you clone the repository and having django installed, + like: + +``` +$ PYTHONPATH=$PYTHONPATH:$PWD django-admin.py test defender --settings=defender.test_settings +``` diff --git a/defender/decorators.py b/defender/decorators.py index 2e3619d..f2ddc0b 100644 --- a/defender/decorators.py +++ b/defender/decorators.py @@ -1,7 +1,7 @@ import logging import socket -import redis +from redis import StrictRedis from django.conf import settings from django.http import HttpResponse from django.http import HttpResponseRedirect @@ -50,7 +50,7 @@ VERBOSE = getattr(settings, 'DEFENDER_VERBOSE', True) ERROR_MESSAGE = ugettext_lazy("Please enter a correct username and password. " "Note that both fields are case-sensitive.") -redis_server = redis.StrictRedis( +redis_server = StrictRedis( host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB, password=REDIS_PASSWORD) log = logging.getLogger(__name__) @@ -198,12 +198,16 @@ def record_failed_attempt(ip, username): return True -def reset_failed_attempts(ip, username): - """ reset the failed attempts for these ip's and usernames """ - redis_server.delete(get_ip_attempt_cache_key(ip)) - redis_server.delete(get_username_attempt_cache_key(username)) - redis_server.delete(get_username_blocked_cache_key(username)) - redis_server.delete(get_ip_blocked_cache_key(ip)) +def reset_failed_attempts(ip=None, username=None): + """ reset the failed attempts for these ip's and usernames + TODO: run all commands in one redis transaction + """ + if ip: + redis_server.delete(get_ip_attempt_cache_key(ip)) + redis_server.delete(get_ip_blocked_cache_key(ip)) + if username: + redis_server.delete(get_username_attempt_cache_key(username)) + redis_server.delete(get_username_blocked_cache_key(username)) def lockout_response(request): diff --git a/defender/middleware.py b/defender/middleware.py new file mode 100644 index 0000000..97fa5b5 --- /dev/null +++ b/defender/middleware.py @@ -0,0 +1,35 @@ +from django.conf import settings +from django.contrib.auth import views as auth_views + +from defender.decorators import watch_login + + +class FailedLoginMiddleware(object): + def __init__(self, *args, **kwargs): + super(FailedLoginMiddleware, self).__init__(*args, **kwargs) + + # watch the auth login + auth_views.login = watch_login(auth_views.login) + + +class ViewDecoratorMiddleware(object): + """ + When the django_axes middleware is installed, by default it watches the + django.auth.views.login. + This middleware allows adding protection to other views without the need + to change any urls or dectorate them manually. + Add this middleware to your MIDDLEWARE settings after + `defender.middleware.FailedLoginMiddleware` and before the django + flatpages middleware. + """ + watched_logins = getattr( + settings, 'DEFENDER_PROTECTED_LOGINS', ( + '/accounts/login/', + ) + ) + + def process_view(self, request, view_func, view_args, view_kwargs): + if request.path in self.watched_logins: + return watch_login(view_func)(request, *view_args, **view_kwargs) + + return None diff --git a/defender/test_settings.py b/defender/test_settings.py new file mode 100644 index 0000000..1bf7006 --- /dev/null +++ b/defender/test_settings.py @@ -0,0 +1,45 @@ +import django + +if django.VERSION[:2] >= (1, 3): + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + } + } +else: + DATABASE_ENGINE = 'sqlite3' + +SITE_ID = 1 + +REDIS_HOST = 'localhost' +REDIS_PORT = '1234' +REDIS_PASSWORD = 'mypassword' +REDIS_DB = 1 + +MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'defender.middleware.FailedLoginMiddleware' +) + +ROOT_URLCONF = 'defender.test_urls' + +INSTALLED_APPS = [ + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + 'django.contrib.admin', + 'defender', +] + +SECRET_KEY = 'too-secret-for-test' + +LOGIN_REDIRECT_URL = '/admin' + +AXES_LOGIN_FAILURE_LIMIT = 10 +from datetime import timedelta +AXES_COOLOFF_TIME = timedelta(seconds=2) diff --git a/defender/test_urls.py b/defender/test_urls.py new file mode 100644 index 0000000..88ad7e7 --- /dev/null +++ b/defender/test_urls.py @@ -0,0 +1,7 @@ +from django.conf.urls import patterns, include +from django.contrib import admin + +urlpatterns = patterns( + '', + (r'^admin/', include(admin.site.urls)), +) diff --git a/defender/tests.py b/defender/tests.py new file mode 100644 index 0000000..5d73b5c --- /dev/null +++ b/defender/tests.py @@ -0,0 +1,160 @@ +import random +import string +import time +from mock import patch +import mockredis + +from django.test import TestCase +from django.contrib.auth.models import User +from django.core.urlresolvers import NoReverseMatch +from django.core.urlresolvers import reverse + +from defender.decorators import ( + COOLOFF_TIME, FAILURE_LIMIT, reset_failed_attempts) + + +# Django >= 1.7 compatibility +try: + ADMIN_LOGIN_URL = reverse('admin:login') + LOGIN_FORM_KEY = '
' +except NoReverseMatch: + ADMIN_LOGIN_URL = reverse('admin:index') + LOGIN_FORM_KEY = 'this_is_the_login_form' + + +class AccessAttemptTest(TestCase): + """Test case using custom settings for testing + """ + VALID_USERNAME = 'valid' + LOCKED_MESSAGE = 'Account locked: too many login attempts.' + + @patch('redis.StrictRedis', mockredis.mock_strict_redis_client) + def the_test(self): + from redis import StrictRedis + print(StrictRedis) + + def the_test2(self): + from redis import StrictRedis + dir(StrictRedis) + print(StrictRedis) + + def _get_random_str(self): + """ Returns a random str """ + chars = string.ascii_uppercase + string.digits + + return ''.join(random.choice(chars) for x in range(20)) + + @patch('redis.StrictRedis', mockredis.mock_strict_redis_client) + def _login(self, is_valid=False, user_agent='test-browser'): + """Login a user. A valid credential is used when is_valid is True, + otherwise it will use a random string to make a failed login. + """ + username = self.VALID_USERNAME if is_valid else self._get_random_str() + + response = self.client.post(ADMIN_LOGIN_URL, { + 'username': username, + 'password': username, + 'this_is_the_login_form': 1, + }, HTTP_USER_AGENT=user_agent) + + return response + + def setUp(self): + """Create a valid user for login + """ + self.user = User.objects.create_superuser( + username=self.VALID_USERNAME, + email='test@example.com', + password=self.VALID_USERNAME, + ) + + @patch('redis.StrictRedis', mockredis.mock_strict_redis_client) + def test_failure_limit_once(self): + """Tests the login lock trying to login one more time + than failure limit + """ + for i in range(0, FAILURE_LIMIT): + response = self._login() + # Check if we are in the same login page + self.assertContains(response, LOGIN_FORM_KEY) + + # So, we shouldn't have gotten a lock-out yet. + # But we should get one now + response = self._login() + self.assertContains(response, self.LOCKED_MESSAGE) + + @patch('redis.StrictRedis', mockredis.mock_strict_redis_client) + def test_failure_limit_many(self): + """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() + # Check if we are in the same login page + self.assertContains(response, LOGIN_FORM_KEY) + + # So, we shouldn't have gotten a lock-out yet. + # But we should get one now + for i in range(0, random.randrange(1, 10)): + # try to log in a bunch of times + response = self._login() + self.assertContains(response, self.LOCKED_MESSAGE) + + @patch('redis.StrictRedis', mockredis.mock_strict_redis_client) + def test_valid_login(self): + """Tests a valid login for a real username + """ + response = self._login(is_valid=True) + self.assertNotContains(response, LOGIN_FORM_KEY, status_code=302) + + @patch('redis.StrictRedis', mockredis.mock_strict_redis_client) + 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()) + + # It should be possible to login again, make sure it is. + self.test_valid_login() + + @patch('redis.StrictRedis', mockredis.mock_strict_redis_client) + def test_cooling_off_for_trusted_user(self): + """Test the cooling time for a trusted user + """ + + # Try the cooling off time + self.test_cooling_off() + + @patch('redis.StrictRedis', mockredis.mock_strict_redis_client) + def test_long_user_agent_valid(self): + """Tests if can handle a long user agent + """ + long_user_agent = 'ie6' * 1024 + response = self._login(is_valid=True, user_agent=long_user_agent) + self.assertNotContains(response, LOGIN_FORM_KEY, status_code=302) + + @patch('redis.StrictRedis', mockredis.mock_strict_redis_client) + def test_long_user_agent_not_valid(self): + """Tests if can handle a long user agent with failure + """ + long_user_agent = 'ie6' * 1024 + for i in range(0, FAILURE_LIMIT + 1): + response = self._login(user_agent=long_user_agent) + + self.assertContains(response, self.LOCKED_MESSAGE) + + @patch('redis.StrictRedis', mockredis.mock_strict_redis_client) + def test_reset_ip(self): + """Tests if can reset an ip address + """ + # Make a lockout + self.test_failure_limit_once() + + # Reset the ip so we can try again + reset_failed_attempts(ip='127.0.0.1') + + # Make a login attempt again + self.test_valid_login() diff --git a/setup.py b/setup.py index 789839d..543cbfd 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,6 @@ setup(name='django-defender', author_email='kencochrane@gmail.com', license='Apache 2', packages=['defender'], - install_requires=['django==1.6.7', 'redis==2.10.3', 'hiredis==0.1.4', ], - + install_requires=['django==1.6.8', 'redis==2.10.3', 'hiredis==0.1.4', ], + tests_require=['mock', 'mockredispy'], )