Skip to content

Commit

Permalink
Initial commit.
Browse files Browse the repository at this point in the history
  • Loading branch information
Sam Chapin committed Apr 27, 2011
0 parents commit 22bc7a8
Show file tree
Hide file tree
Showing 15 changed files with 903 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# python compiled files
*.pyc
*.pyo

# back up files from VIM / Emacs
*~
*.swp

28 changes: 28 additions & 0 deletions LICENSE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
Copyright (c) 2011, SD Elements
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. All advertising materials mentioning features or use of this software
must display the following acknowledgement:
This product includes software developed by SD Elements.
4. Neither the name of SD Elements nor the names of its contributors may be
used to endorse or promote products derived from this software without
specific prior written permission.

THIS SOFTWARE IS PROVIDED BY SD Elements ''AS IS'' AND ANY EXPRESS OR IMPLIED
WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
EVENT SHALL SD Elements BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.

Empty file added security/__init__.py
Empty file.
35 changes: 35 additions & 0 deletions security/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright (c) 2011, SD Elements. See LICENSE.txt for details.

import re

from django.core.validators import RegexValidator
from django.forms import ValidationError as VE
from django.utils.translation import gettext as _


def min_length(n):
"""
Returns a validator that fails on finding too few characters. Necessary
because django.core.validators.MinLengthValidator doesn't take a message
argument.
"""
def validate(password):
if len(password) < n:
raise VE(_("It must contain at least %d characters.") % n)
return validate

# The error messages from the RegexValidators don't display properly unless we
# explicitly supply an empty error code.

lowercase = RegexValidator(r"[a-z]",
_("It must contain at least one lowercase letter."),
'')

uppercase = RegexValidator(r"[A-Z]",
_("It must contain at least one uppercase letter."),
'')

digit = RegexValidator(r"[0-9]",
_("It must contain at least one decimal digit."),
'')

169 changes: 169 additions & 0 deletions security/auth_throttling/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# Copyright (c) 2011, SD Elements. See ../LICENSE.txt for details.

import logging
from math import ceil
import re
import time # Monkeypatched by the tests.

from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.sites.models import get_current_site
from django.core.cache import cache
from django.forms import ValidationError
from django.shortcuts import render_to_response
from django.template import RequestContext
from django.utils.translation import ugettext as _
from django.views.decorators.csrf import csrf_protect

import settings


logger = logging.getLogger(__name__)

def delay_message(remainder):
"""
A natural-language description of a delay period.
"""
# TODO: There's probably a library for this.
minutes = round(float(remainder) / 60)
return (_("1 minute") if minutes == 1 else
_("%d minutes") % minutes if minutes > 1 else
_("1 second") if ceil(remainder) == 1 else
_("%d seconds") % ceil(remainder))

def _key(counter_type, counter_name):
return "security.authentication_throttling.%s:%s" % (counter_type,
counter_name)

def reset_counters(**counters):
cache.delete_many([_key(*pair) for pair in counters.items()])

def increment_counters(**counters):
"""
Each keyword is a counter type (e.g. "username", "ip") and each argument
is an identifier of that type. Increments (creating if not already present)
each counter.
"""
t = time.time()
keys = [_key(*pair) for pair in counters.items()]
existing = cache.get_many(keys)
for key in keys:
existing[key] = (existing.get(key, (0,))[0] + 1, t)
cache.set_many(existing)

def attempt_count(attempt_type, id):
"""
Only used by tests.
"""
return cache.get(_key(attempt_type, id), (0,))[0]

def register_authentication_attempt(request):
"""
The given request is a login attempt that has already passed through the
authentication middleware. Adjusts the throttling counters based on whether
it succeeded or failed.
"""
(reset_counters if request.user.is_authenticated() else increment_counters
)(username=request.POST["username"], ip=request.META["REMOTE_ADDR"])


class _ThrottlingForm(AuthenticationForm):
def __init__(self, throttling_delay, *args, **kwargs):
super(_ThrottlingForm, self).__init__(*args, **kwargs)
self._errors = {"__all__":
self.error_class(["Due to the failure of previous "
"attempts, your login request "
"has been denied as a security "
"precaution. Please try again "
"in at least %s. " %
delay_message(throttling_delay)
])}


class Middleware:
"""
Performs authentication throttling by username and IP. Expects a settings
dict named AUTHENTICATION_THROTTLING with at least two elements,
LOGIN_URLS_WITH_TEMPLATES and DELAY_FUNCTION. The former is a list of
pairs of URL and template path. The latter is a function of two arguments,
called when a request is being considered for throttling: The first
argument is the number of failed attempts that have been made on the
username being supplied since the last success, and the second argument is
the number of attempts from the IP. The function should return a pair: The
number of seconds to delay the next attempt on that username, and the
number of seconds to delay the next attempt from that IP.
Any POST request to one of the supplied login URLs is assumed to be a login
attempt. The Django cache is used to store failure counts and timestamps:
If it is found that there is a throttling delay applicable to the attempt
that has not yet elapsed, a response is returned using the template for
that URL, with an error informing the user of the situation. Otherwise,
the request is allowed to continue, and a response handler checks
request.user to determine whether the login attempt succeeded.
"""

def __init__(self):
"""
Looks for a valid configuration in settings.AUTHENTICATION_THROTTLING.
If such is not found, the handlers are not installed.
"""
try:
config = settings.AUTHENTICATION_THROTTLING
self.delay_function = config["DELAY_FUNCTION"]
self.logins = list(config["LOGIN_URLS_WITH_TEMPLATES"])
self.redirect_field_name = config.get("REDIRECT_FIELD_NAME",
REDIRECT_FIELD_NAME)
# TODO: Test the validity of the list items?
self.process_request = self._process_request_if_configured
self.process_response = self._process_response_if_configured
except:
logger.error("Bad AUTHENTICATION_THROTTLING dictionary. "
"AuthenticationThrottlingMiddleware disabled.")

def _throttling_delay(self, request):
"""
Return the greater of the delay periods called for by the username and
the IP of this login request.
"""
t = time.time()
acc_n, acc_t = cache.get(_key("username", request.POST["username"]),
(0, t))
ip_n, ip_t = cache.get(_key("ip", request.META["REMOTE_ADDR"]), (0, t))
acc_delay, ip_delay = self.delay_function(acc_n, ip_n)
return max(acc_t + acc_delay - t, ip_t + ip_delay - t)

def _process_request_if_configured(self, request):
"""
Block the request if it is a login attempt to which a throttling delay
is applicable.
"""
if request.method != "POST": return
for url, template_name in self.logins:
if request.path[1:] != url: continue
delay = self._throttling_delay(request)
if delay <= 0:
request.META["login_request_permitted"] = True
return
form = _ThrottlingForm(delay, request)
redirect_url = request.REQUEST.get(self.redirect_field_name, "")
current_site = get_current_site(request)
# Template-compatible with 'django.contrib.auth.views.login'.
return csrf_protect(lambda request:
render_to_response(template_name,
{"form":
form,
self.redirect_field_name:
redirect_url,
"site":
current_site,
"site_name":
current_site.name},
context_instance=
RequestContext(request))
)(request)

def _process_response_if_configured(self, request, response):
if request.META.get("login_request_permitted", False):
register_authentication_attempt(request)
return response

29 changes: 29 additions & 0 deletions security/auth_throttling/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copyright (c) 2011, SD Elements. See ../LICENSE.txt for details.

import logging

from django.contrib.auth.models import User
from django.http import Http404, HttpResponseRedirect
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_http_methods

from security.auth_throttling import reset_counters

logger = logging.getLogger(__name__)


@never_cache
@require_http_methods(["POST"])
def reset_username_throttle(request, user_id=None, redirect_url="/"):
if not request.user.is_superuser:
raise Http404
try:
username = User.objects.get(id=user_id).username
except:
logger.error("Couldn't find username for user id %s." % user_id)
raise Http404()
reset_counters(username=username)
logger.info("Authentication throttling reset for user id %s." % user_id)
# TODO: Sanitize redirect_url, even though it's coming from an admin?
return HttpResponseRedirect(redirect_url)

27 changes: 27 additions & 0 deletions security/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Copyright (c) 2011, SD Elements. See LICENSE.txt for details.

from django import forms
import django.contrib.auth.forms

from django.utils.translation import ugettext_lazy as _

import auth
from password_expiry import password_is_expired, never_expire_password


class PasswordChangeForm(django.contrib.auth.forms.PasswordChangeForm):
new_password1 = forms.CharField(label=_("New password"),
widget=forms.PasswordInput,
validators=[auth.min_length(6),
auth.uppercase,
auth.lowercase,
auth.digit])

def __init__(self, *args, **kwargs):
super(PasswordChangeForm, self).__init__(*args, **kwargs)
self.user_is_new = password_is_expired(self.user)

def save(self, *args, **kwargs):
super(PasswordChangeForm, self).save(*args, **kwargs)
never_expire_password(self.user)

Loading

0 comments on commit 22bc7a8

Please sign in to comment.