From 92c378bf681f51d68451477e260d5bf72212f8f3 Mon Sep 17 00:00:00 2001 From: Ken Cochrane Date: Sat, 3 Jan 2015 16:33:51 -0500 Subject: [PATCH] Add Celery option for writing to database --- .coveragerc | 2 ++ defender/.landscape.yaml => .landscape.yaml | 3 +++ .travis.yml | 8 +++--- README.md | 3 +++ defender/config.py | 2 ++ defender/data.py | 14 ++++++++++ defender/decorators.py | 2 +- defender/tasks.py | 15 +++++++++++ defender/test_settings.py | 19 ++++++++++++++ defender/tests.py | 24 ++++++++++++++++- defender/travis_settings.py | 19 ++++++++++++++ defender/utils.py | 29 ++++++++++++--------- setup.py | 2 +- 13 files changed, 124 insertions(+), 18 deletions(-) create mode 100644 .coveragerc rename defender/.landscape.yaml => .landscape.yaml (68%) create mode 100644 defender/data.py create mode 100644 defender/tasks.py 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/defender/.landscape.yaml b/.landscape.yaml similarity index 68% rename from defender/.landscape.yaml rename to .landscape.yaml index 645700e..ce25ea0 100644 --- a/defender/.landscape.yaml +++ b/.landscape.yaml @@ -4,4 +4,7 @@ strictness: veryhigh max-line-length: 80 uses: - django + - celery autodetect: yes +ignore-patterns: + - .*_settings.py$ diff --git a/.travis.yml b/.travis.yml index bb7f682..afa219d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,9 +9,10 @@ python: - "pypy" env: - - DJANGO=Django==1.4.16 - - DJANGO=Django==1.6.8 - - DJANGO=Django==1.7.1 + - DJANGO=Django==1.4.17 + - DJANGO=Django==1.5.12 + - DJANGO=Django==1.6.9 + - DJANGO=Django==1.7.2 services: - redis-server @@ -20,6 +21,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 368d5be..98456d6 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,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/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 eda3e9d..a599e14 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'], )