diff --git a/.travis.yml b/.travis.yml index f6633b6..c395dd4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,7 +15,7 @@ install: script: coverage run setup.py test before_script: - - pep8 --ignore=E501 secure_login/ example/ + - pep8 --ignore=E501 secure_login/ after_success: coveralls diff --git a/README.md b/README.md index f898ecc..49e03a7 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,13 @@ Working * Ensure that the password is not in the list of known weak passwords. * Ensure username is not same as password * Email user on a failed login attempt for them. +* Lockout after 10 failed attemps within an hour. TODO --------- * Rate limits login attempts per IP. * Rate limits login attempts per user. -* Lockout after X failed attemps. * Emails admins on X failed attempts. * Integrate with fail2ban. * Support 2F authentication diff --git a/docs/usage.md b/docs/usage.md index 881d9ac..d719a27 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -22,10 +22,30 @@ And "secure_login.checkers.no_short_passwords", ] -`SECURE_LOGIN_CHECKERS` should be a list of callables. Each callable should only return true if it wants the authetication to go through. +`SECURE_LOGIN_CHECKERS` should be a list of callables. Each callable should only return true if it wants the authentication to go through. And SECURE_LOGIN_ON_FAIL = [ "secure_login.on_fail.email_user", - ] \ No newline at end of file + "secure_login.on_fail.populate_failed_requests", + ] + +`SECURE_LOGIN_ON_FAIL` should be a list of callables. Each callable would be called in order if the authentication falls. + +Writing new secure backends. +----------------------------------- + +If you have an existing backend `FooBackend`, you can add SecureBackend like this. + + class SecureFooLoginBackend(SecureLoginBackendMixin, FooBackend): + pass + + +Secure Form +----------------- + +Use the `SecureFormMixin` with your usual forms. The forms must have username and password fields. + +`SECURE_LOGIN_CHECKERS` will be tested in the the clean method. + diff --git a/example/__init__.py b/example/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/example/example/__init__.py b/example/example/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/example/example/settings.py b/example/example/settings.py deleted file mode 100644 index 0d42150..0000000 --- a/example/example/settings.py +++ /dev/null @@ -1,85 +0,0 @@ -""" -Django settings for example project. - -For more information on this file, see -https://docs.djangoproject.com/en/1.6/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/1.6/ref/settings/ -""" - -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -import os -BASE_DIR = os.path.dirname(os.path.dirname(__file__)) - - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/1.6/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'zupz)8bv7$#t&_k&1%8sh$wa3jamjqho-#b5u9-ps!4rz@!fe^' - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -TEMPLATE_DEBUG = True - -ALLOWED_HOSTS = [] - - -# Application definition - -INSTALLED_APPS = ( - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'secure_login', -) - -MIDDLEWARE_CLASSES = ( - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', -) - -AUTHENTICATION_BACKENDS = ("secure_login.backends.SecureLoginBackend", ) - -ROOT_URLCONF = 'example.urls' - -WSGI_APPLICATION = 'example.wsgi.application' - - -# Database -# https://docs.djangoproject.com/en/1.6/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), - } -} - -# Internationalization -# https://docs.djangoproject.com/en/1.6/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - -USE_I18N = True - -USE_L10N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/1.6/howto/static-files/ - -STATIC_URL = '/static/' diff --git a/example/example/urls.py b/example/example/urls.py deleted file mode 100644 index 0355ddd..0000000 --- a/example/example/urls.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.conf.urls import patterns, include, url - -from django.contrib import admin -admin.autodiscover() -urlpatterns = patterns( - '', - # Examples: - # url(r'^$', 'example.views.home', name='home'), - # url(r'^blog/', include('blog.urls')), - - url(r'^admin/', include(admin.site.urls)), -) diff --git a/example/example/wsgi.py b/example/example/wsgi.py deleted file mode 100644 index 2cc360a..0000000 --- a/example/example/wsgi.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -WSGI config for example project. - -It exposes the WSGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/1.6/howto/deployment/wsgi/ -""" - -import os -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") - -from django.core.wsgi import get_wsgi_application -application = get_wsgi_application() diff --git a/example/manage.py b/example/manage.py deleted file mode 100644 index 2605e37..0000000 --- a/example/manage.py +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env python -import os -import sys - -if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") - - from django.core.management import execute_from_command_line - - execute_from_command_line(sys.argv) diff --git a/secure_login/backends.py b/secure_login/backends.py index fd8bdf1..f276afc 100644 --- a/secure_login/backends.py +++ b/secure_login/backends.py @@ -2,13 +2,7 @@ from django.conf import settings -def get_callable(callable_str): - path = callable_str.split(".") - module_name = ".".join(path[:-1]) - callable_name = path[-1] - module = __import__(module_name, {}, {}, [callable_name]) - callable_ = getattr(module, callable_name) - return callable_ +from .utils import get_callable class SecureLoginBackendMixin(object): @@ -19,6 +13,7 @@ def authenticate(self, username=None, password=None, **kwargs): "secure_login.checkers.no_username_password_same"] checkers = getattr(settings, "SECURE_LOGIN_CHECKERS", DEFAULT_CHECKERS) + request = kwargs.pop('request', None) DEFAULT_ON_FAIL = ["secure_login.on_fail.email_user", ] on_fail_callables = getattr(settings, diff --git a/secure_login/forms.py b/secure_login/forms.py new file mode 100644 index 0000000..698745d --- /dev/null +++ b/secure_login/forms.py @@ -0,0 +1,26 @@ +from django.conf import settings +from django.contrib.auth.forms import AuthenticationForm +from django import forms + +from .utils import get_callable + +DEFAULT_ERROR_MESSAGE = "Please review the username and password" + + +class SecureFormMixin(object): + + def clean(self): + DEFAULT_CHECKERS = ["secure_login.checkers.no_weak_passwords", + "secure_login.checkers.no_short_passwords", + "secure_login.checkers.no_username_password_same"] + + checkers = getattr(settings, "SECURE_LOGIN_CHECKERS", DEFAULT_CHECKERS) + for checker in checkers: + checker_ = get_callable(checker) + if not checker_(**self.cleaned_data): + raise forms.ValidationError(getattr(checker_, "error_message", DEFAULT_ERROR_MESSAGE)) + return super(SecureFormMixin, self).clean() + + +class SecureLoginForm(SecureFormMixin, AuthenticationForm): + pass diff --git a/secure_login/models.py b/secure_login/models.py index 71a8362..7ef6d0f 100644 --- a/secure_login/models.py +++ b/secure_login/models.py @@ -1,3 +1,9 @@ from django.db import models -# Create your models here. +from django.contrib.auth.models import User + + +class FailedLogin(models.Model): + attempted_at = models.DateTimeField(auto_now=True) + for_user = models.ForeignKey(User, null=True, blank=True) + ip = models.GenericIPAddressField(null=True, blank=True) diff --git a/secure_login/on_fail.py b/secure_login/on_fail.py index beac6ea..d6366ee 100644 --- a/secure_login/on_fail.py +++ b/secure_login/on_fail.py @@ -3,6 +3,10 @@ from django.core.mail import send_mail from django.conf import settings +from .models import FailedLogin + +import datetime + def email_user(username, password, **kwargs): try: @@ -15,3 +19,39 @@ def email_user(username, password, **kwargs): except User.DoesNotExist: pass + + +def populate_failed_requests(username, password, **kwargs): + request = kwargs.get("request") + if request and get_client_ip(request): + ip = get_client_ip(request) + else: + ip = None + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + user = None + FailedLogin.objects.create(for_user=user, ip=ip) + + +def lockout_on_many_wrong_password(username, password, **kwargs): + try: + user = User.objects.get(username=username) + one_hour_ago = datetime.datetime.now() - datetime.timedelta(hours=1) + failed_logins = FailedLogin.objects.filter(for_user=user, attempted_at__gte=one_hour_ago).count() + max_hourly_attempts = getattr(settings, "SECURE_LOGIN_MAX_HOURLY_ATTEMPTS", 10) + if failed_logins >= max_hourly_attempts: + user.is_active = False + user.save() + except User.DoesNotExist: + pass + + +def get_client_ip(request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + ip = None + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip diff --git a/secure_login/tests.py b/secure_login/tests.py index 311c465..daad81c 100644 --- a/secure_login/tests.py +++ b/secure_login/tests.py @@ -3,15 +3,16 @@ from django.contrib.auth import authenticate from django.core import mail from django.test.utils import override_settings +from django.conf import settings +from django import forms + +from .models import FailedLogin +from .forms import SecureLoginForm, SecureFormMixin class SecureLoginBackendTest(TestCase): - @override_settings( - SECURE_LOGIN_CHECKERS=[ - "secure_login.checkers.no_weak_passwords", - ] - ) + @override_settings(SECURE_LOGIN_CHECKERS=["secure_login.checkers.no_weak_passwords", ]) def test_no_weak_passwords(self): bad_password = "albatross" good_password = "a-l0ng-pa55w0rd-@^&" @@ -22,14 +23,10 @@ def test_no_weak_passwords(self): user.set_password(good_password) user.save() - self.assertEqual(authenticate(username="hello", - password=good_password), user) - - @override_settings( - SECURE_LOGIN_CHECKERS=[ - "secure_login.checkers.no_short_passwords", - ] - ) + self.assertEqual( + authenticate(username="hello", password=good_password), user) + + @override_settings(SECURE_LOGIN_CHECKERS=["secure_login.checkers.no_short_passwords", ]) def test_no_short_passwords(self): bad_password = "123" good_password = "a-l0ng-pa55w0rd-@^&" @@ -40,14 +37,10 @@ def test_no_short_passwords(self): user.set_password(good_password) user.save() - self.assertEqual(authenticate(username="hello", - password=good_password), user) - - @override_settings( - SECURE_LOGIN_CHECKERS=[ - "secure_login.checkers.no_username_password_same", - ] - ) + self.assertEqual( + authenticate(username="hello", password=good_password), user) + + @override_settings(SECURE_LOGIN_CHECKERS=["secure_login.checkers.no_username_password_same", ]) def test_no_username_password_same(self): username = "hellohello" bad_password = "hellohello" @@ -55,19 +48,15 @@ def test_no_username_password_same(self): user = User.objects.create(username=username) user.set_password(bad_password) user.save() - self.assertFalse(authenticate(username=username, - password=bad_password)) + self.assertFalse( + authenticate(username=username, password=bad_password)) user.set_password(good_password) user.save() - self.assertEqual(authenticate(username=username, - password=good_password), user) - - @override_settings( - SECURE_LOGIN_ON_FAIL=[ - "secure_login.on_fail.email_user", - ] - ) + self.assertEqual( + authenticate(username=username, password=good_password), user) + + @override_settings(SECURE_LOGIN_ON_FAIL=["secure_login.on_fail.email_user", ]) def test_email_sent_on_wrong_password(self): username = "hello" password = "hellohello" @@ -75,23 +64,99 @@ def test_email_sent_on_wrong_password(self): user.set_password(password) user.save() - self.assertFalse(authenticate(username=username, - password=password + "1")) + self.assertFalse( + authenticate(username=username, password=password + "1")) self.assertEqual(len(mail.outbox), 1) - @override_settings( - SECURE_LOGIN_CHECKERS=[ - "secure_login.checkers.no_weak_passwords", - ] - ) - def test_email_sent_on_weak_password(self): + @override_settings(SECURE_LOGIN_ON_FAIL=["secure_login.on_fail.populate_failed_requests"], SECURE_LOGIN_CHECKERS=[]) + def test_populate_failed_requests(self): username = "hello" - password = "hello" + password = "hellohello" + user = User.objects.create_user(username=username, password=password) + + authenticate(username=username, password="not-the-correct-password") + self.assertEqual(FailedLogin.objects.count(), 1) + + @override_settings(SECURE_LOGIN_ON_FAIL=["secure_login.on_fail.populate_failed_requests", "secure_login.on_fail.lockout_on_many_wrong_password", ], SECURE_LOGIN_CHECKERS=[]) + def test_lockout(self): + username = "hello" + password = "hellohello" + user = User.objects.create_user(username=username, password=password) + + for _ in range(11): + authenticate( + username=username, password="not-the-correct-password") + user_ = authenticate(username=username, password=password) + self.assertFalse(user_.is_active) + + +class FormsTest(TestCase): + + @override_settings(SECURE_LOGIN_CHECKERS=["secure_login.checkers.no_weak_passwords", ]) + def test_no_weak_passwords(self): + bad_password = "albatross" good_password = "a-l0ng-pa55w0rd-@^&" - user = User.objects.create(username=username) + + user = User.objects.create(username="hello") + user.set_password(bad_password) + user.save() + + form = SecureLoginForm( + data={"username": "hello", "password": bad_password}) + self.assertFalse(form.is_valid()) + user.set_password(good_password) user.save() + form = SecureLoginForm( + data={"username": "hello", "password": good_password}) + self.assertTrue(form.is_valid()) - self.assertFalse(authenticate(username=username, - password=password)) - self.assertEqual(len(mail.outbox), 1) + @override_settings(SECURE_LOGIN_CHECKERS=["secure_login.checkers.no_short_passwords", ]) + def test_no_short_passwords(self): + bad_password = "123" + good_password = "a-l0ng-pa55w0rd-@^&" + user = User.objects.create(username="hello") + user.set_password(bad_password) + user.save() + form = SecureLoginForm( + data={"username": "hello", "password": bad_password}) + + self.assertFalse(form.is_valid()) + + user.set_password(good_password) + user.save() + form = SecureLoginForm( + data={"username": "hello", "password": good_password}) + self.assertTrue(form.is_valid()) + + def test_register_form(self): + class RegiterForm(forms.Form): + username = forms.CharField(max_length=50) + password = forms.CharField() + email = forms.EmailField(required=False) + + class SecureRegisterForm(SecureFormMixin, RegiterForm): + pass + + bad_password = "albatross" + + with self.settings(SECURE_LOGIN_CHECKERS=["secure_login.checkers.no_weak_passwords", ]): + form = SecureRegisterForm( + data={"username": "hello", "password": bad_password}) + self.assertFalse(form.is_valid()) + + with self.settings(SECURE_LOGIN_CHECKERS=[]): + form = SecureRegisterForm( + data={"username": "hello", "password": bad_password}) + self.assertTrue(form.is_valid()) + + bad_password = 123 + with self.settings(SECURE_LOGIN_CHECKERS=["secure_login.checkers.no_short_passwords", ]): + form = SecureRegisterForm( + data={"username": "hello", "password": bad_password}) + self.assertFalse(form.is_valid()) + + with self.settings(SECURE_LOGIN_CHECKERS=[]): + form = SecureRegisterForm( + data={"username": "hello", "password": bad_password}) + self.assertTrue(form.is_valid()) diff --git a/secure_login/utils.py b/secure_login/utils.py new file mode 100644 index 0000000..fa4c77f --- /dev/null +++ b/secure_login/utils.py @@ -0,0 +1,7 @@ +def get_callable(callable_str): + path = callable_str.split(".") + module_name = ".".join(path[:-1]) + callable_name = path[-1] + module = __import__(module_name, {}, {}, [callable_name]) + callable_ = getattr(module, callable_name) + return callable_