Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lockout and docs #8

Merged
merged 10 commits into from Feb 25, 2014
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .travis.yml
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Expand Up @@ -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
24 changes: 22 additions & 2 deletions docs/usage.md
Expand Up @@ -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",
]
"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.

Empty file removed example/__init__.py
Empty file.
Empty file removed example/example/__init__.py
Empty file.
85 changes: 0 additions & 85 deletions example/example/settings.py

This file was deleted.

12 changes: 0 additions & 12 deletions example/example/urls.py

This file was deleted.

14 changes: 0 additions & 14 deletions example/example/wsgi.py

This file was deleted.

10 changes: 0 additions & 10 deletions example/manage.py

This file was deleted.

9 changes: 2 additions & 7 deletions secure_login/backends.py
Expand Up @@ -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):
Expand All @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions 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
8 changes: 7 additions & 1 deletion 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)
40 changes: 40 additions & 0 deletions secure_login/on_fail.py
Expand Up @@ -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:
Expand All @@ -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