diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..a6a680d --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit = *_settings.py diff --git a/.landscape.yaml b/.landscape.yaml new file mode 100644 index 0000000..ee0d355 --- /dev/null +++ b/.landscape.yaml @@ -0,0 +1,10 @@ +doc-warnings: no +test-warnings: no +strictness: high +max-line-length: 80 +uses: + - django + - celery +autodetect: yes +ignore-patterns: + - .*_settings.py$ diff --git a/.travis.yml b/.travis.yml index 47b87f3..a2d499f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,7 @@ python: env: - DJANGO=Django==1.4.17 + - DJANGO=Django==1.5.12 - DJANGO=Django==1.6.9 - DJANGO=Django==1.7.2 @@ -19,6 +20,7 @@ install: - pip install -q $DJANGO - pip install coveralls - pip install mockredispy + - pip install celery - python setup.py develop script: diff --git a/README.md b/README.md index 5db087e..58427c6 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ to improve the login. requirements ============ -- django: 1.4.x, 1.6.x, 1.7.x +- django: 1.4.x, 1.5.x, 1.6.x, 1.7.x - redis - python: 2.6.x, 2.7.x, 3.3.x, 3.4.x, PyPy @@ -263,6 +263,9 @@ Default: ``redis://localhost:6379/0`` (Example with password: ``redis://:mypassword@localhost:6379/0``) * ``DEFENDER_PROTECTED_LOGINS``: Tuple: Used by ``ViewDecoratorMiddleware`` to decide which login urls need protecting. Default: ``('/accounts/login/',)`` +* ``DEFENDER_USE_CELERY``: Boolean: If you want to use Celery to store the login +attempt to the database, set to True. If False, it is saved inline. +Default: ``False`` Running Tests ============= diff --git a/defender/.landscape.yaml b/defender/.landscape.yaml deleted file mode 100644 index 645700e..0000000 --- a/defender/.landscape.yaml +++ /dev/null @@ -1,7 +0,0 @@ -doc-warnings: yes -test-warnings: no -strictness: veryhigh -max-line-length: 80 -uses: - - django -autodetect: yes diff --git a/defender/config.py b/defender/config.py index d489cac..6e7a5a3 100644 --- a/defender/config.py +++ b/defender/config.py @@ -50,3 +50,5 @@ LOCKOUT_URL = get_setting('DEFENDER_LOCKOUT_URL') PROTECTED_LOGINS = get_setting('DEFENDER_PROTECTED_LOGINS', ('/accounts/login/',)) + +USE_CELERY = get_setting('DEFENDER_USE_CELERY', False) diff --git a/defender/data.py b/defender/data.py new file mode 100644 index 0000000..8febfb9 --- /dev/null +++ b/defender/data.py @@ -0,0 +1,14 @@ +from .models import AccessAttempt + + +def store_login_attempt(user_agent, ip_address, username, + http_accept, path_info, login_valid): + """ Store the login attempt to the db. """ + AccessAttempt.objects.create( + user_agent=user_agent, + ip_address=ip_address, + username=username, + http_accept=http_accept, + path_info=path_info, + login_valid=login_valid, + ) diff --git a/defender/decorators.py b/defender/decorators.py index b5ce8bb..5bdf5cd 100644 --- a/defender/decorators.py +++ b/defender/decorators.py @@ -34,7 +34,7 @@ def watch_login(func): # ideally make this background task, but to keep simple, keeping # it inline for now. - utils.add_login_attempt(request, not login_unsuccessful) + utils.add_login_attempt_to_db(request, not login_unsuccessful) if utils.check_request(request, login_unsuccessful): return response diff --git a/defender/tasks.py b/defender/tasks.py new file mode 100644 index 0000000..db3498a --- /dev/null +++ b/defender/tasks.py @@ -0,0 +1,15 @@ +from . import config +from .data import store_login_attempt + +# not sure how to get this to look better. ideally we want to dynamically +# apply the celery decorator based on the USE_CELERY setting. + +if config.USE_CELERY: + from celery import shared_task + + @shared_task() + def add_login_attempt_task(user_agent, ip_address, username, + http_accept, path_info, login_valid): + """ Create a record for the login attempt """ + store_login_attempt(user_agent, ip_address, username, + http_accept, path_info, login_valid) diff --git a/defender/test_settings.py b/defender/test_settings.py index a515e37..848048c 100644 --- a/defender/test_settings.py +++ b/defender/test_settings.py @@ -39,3 +39,22 @@ DEFENDER_COOLOFF_TIME = 2 DEFENDER_REDIS_URL = None # use mock redis in unit tests locally. DEFENDER_MOCK_REDIS = True + +# celery settings +CELERY_ALWAYS_EAGER = True +BROKER_BACKEND = 'memory' +BROKER_URL = 'memory://' + +import os + +from celery import Celery + +# set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'defender.test_settings') + +app = Celery('defender') + +# Using a string here means the worker will not have to +# pickle the object when using Windows. +app.config_from_object('django.conf:settings') +app.autodiscover_tasks(lambda: INSTALLED_APPS) diff --git a/defender/tests.py b/defender/tests.py index 178731e..68c9647 100644 --- a/defender/tests.py +++ b/defender/tests.py @@ -294,7 +294,7 @@ class AccessAttemptTest(TestCase): response = self._login() self.assertContains(response, LOGIN_FORM_KEY) self.assertEquals(AccessAttempt.objects.count(), 1) - self.assertIsNotNone(AccessAttempt.objects.all()[0]) + self.assertIsNotNone(str(AccessAttempt.objects.all()[0])) def test_is_valid_ip(self): """ Test the is_valid_ip() method @@ -413,6 +413,7 @@ class AccessAttemptTest(TestCase): req = HttpRequest() req.META['HTTP_X_PROXIED'] = '1.2.3.4' self.assertEqual(utils.get_ip(req), '1.2.3.4') + req = HttpRequest() req.META['HTTP_X_PROXIED'] = '1.2.3.4, 5.6.7.8, 127.0.0.1' self.assertEqual(utils.get_ip(req), '1.2.3.4') @@ -450,6 +451,7 @@ class AccessAttemptTest(TestCase): ) def test_admin(self): + """ test the admin pages for this app """ from .admin import AccessAttemptAdmin AccessAttemptAdmin @@ -484,3 +486,23 @@ class AccessAttemptTest(TestCase): self.assertContains(response, LOGIN_FORM_KEY) response = self.client.get(ADMIN_LOGIN_URL) self.assertNotContains(response, self.LOCKED_MESSAGE) + + @patch('defender.config.USE_CELERY', True) + def test_use_celery(self): + """ Check that use celery works""" + + self.assertEquals(AccessAttempt.objects.count(), 0) + + for i in range(0, int(config.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) + + self.assertEquals(AccessAttempt.objects.count(), + config.FAILURE_LIMIT+1) + self.assertIsNotNone(str(AccessAttempt.objects.all()[0])) diff --git a/defender/travis_settings.py b/defender/travis_settings.py index 89bdaa0..fba68e5 100644 --- a/defender/travis_settings.py +++ b/defender/travis_settings.py @@ -39,3 +39,22 @@ DEFENDER_COOLOFF_TIME = 2 DEFENDER_REDIS_URL = "redis://localhost:6379/1" # don't use mock redis in unit tests, we will use real redis on travis. DEFENDER_MOCK_REDIS = False + +# Celery settings: +CELERY_ALWAYS_EAGER = True +BROKER_BACKEND = 'memory' +BROKER_URL = 'memory://' + +import os + +from celery import Celery + +# set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'defender.travis_settings') + +app = Celery('defender') + +# Using a string here means the worker will not have to +# pickle the object when using Windows. +app.config_from_object('django.conf:settings') +app.autodiscover_tasks(lambda: INSTALLED_APPS) diff --git a/defender/utils.py b/defender/utils.py index d2f8214..1ab5ca2 100644 --- a/defender/utils.py +++ b/defender/utils.py @@ -6,9 +6,9 @@ from django.http import HttpResponseRedirect from django.shortcuts import render_to_response from django.template import RequestContext -from .models import AccessAttempt from .connection import get_redis_connection from . import config +from .data import store_login_attempt redis_server = get_redis_connection() @@ -233,14 +233,19 @@ def check_request(request, login_unsuccessful): return record_failed_attempt(ip_address, username) -def add_login_attempt(request, login_valid): - """ Create a record for the login attempt """ - AccessAttempt.objects.create( - user_agent=request.META.get('HTTP_USER_AGENT', - '')[:255], - ip_address=get_ip(request), - username=request.POST.get(config.USERNAME_FORM_FIELD, None), - http_accept=request.META.get('HTTP_ACCEPT', ''), - path_info=request.META.get('PATH_INFO', ''), - login_valid=login_valid, - ) +def add_login_attempt_to_db(request, login_valid): + """ Create a record for the login attempt If using celery call celery + task, if not, call the method normally """ + user_agent = request.META.get('HTTP_USER_AGENT', '')[:255] + ip_address = get_ip(request) + username = request.POST.get(config.USERNAME_FORM_FIELD, None) + http_accept = request.META.get('HTTP_ACCEPT', '') + path_info = request.META.get('PATH_INFO', '') + + if config.USE_CELERY: + from .tasks import add_login_attempt_task + add_login_attempt_task.delay(user_agent, ip_address, username, + http_accept, path_info, login_valid) + else: + store_login_attempt(user_agent, ip_address, username, + http_accept, path_info, login_valid) diff --git a/setup.py b/setup.py index f6544fb..ba8b722 100644 --- a/setup.py +++ b/setup.py @@ -38,5 +38,5 @@ setup(name='django-defender', license='Apache 2', packages=['defender'], install_requires=['django==1.6.8', 'redis==2.10.3', 'hiredis==0.1.4', ], - tests_require=['mock', 'mockredispy', 'coverage'], + tests_require=['mock', 'mockredispy', 'coverage', 'celery'], )