-
Notifications
You must be signed in to change notification settings - Fork 49
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Sam Chapin
committed
Apr 27, 2011
0 parents
commit 22bc7a8
Showing
15 changed files
with
903 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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."), | ||
'') | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
|
Oops, something went wrong.