GC #1 - I've implemented some of the groovy features offered by philipn. Thanks!

This commit is contained in:
codekoala 2009-12-16 23:24:30 -05:00
parent 74676db7dc
commit eafc81b7d8
6 changed files with 164 additions and 48 deletions

View file

@ -1,6 +1,6 @@
The MIT License
Copyright (c) 2008 Josh VanderLinden
Copyright (c) 2008 Josh VanderLinden, 2009 Philip Neustrom <philipn@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

11
README
View file

@ -1,7 +1,7 @@
django-axes is a very simple way for you to keep track of failed login attempts, both for the Django admin and for the rest of your site. The name is sort of a geeky pun, since `axes` can be read interpretted as:
# "access", as in monitoring access attempts
# "axes", as in tools you can use hack (generally on wood). In this case, however, the "hacking" part of it can be taken a bit further: `django-axes` is intended to help you *stop* people from hacking (popular media definition) your website. Hilarious, right? That's what I thougth too!
# "axes", as in tools you can use hack (generally on wood). In this case, however, the "hacking" part of it can be taken a bit further: `django-axes` is intended to help you *stop* people from hacking (popular media definition) your website. Hilarious, right? That's what I thought too!
==Requirements==
@ -77,9 +77,12 @@ Run `manage.py syncdb`. This creates the appropriate tables in your database th
You have a couple options available to you to customize `django-axes` a bit. These should be defined in your `settings.py` file.
* `LOGIN_FAILURE_LIMIT`: The number of login attempts allowed before a record is created for the failed logins. Default: `3`
* `LOGIN_FAILURE_RESET`: Determines whether or not the number of failed attempts will be reset after a failed login record is created. If set to `False`, the application should maintain the number of failed login attempts for a particular user from the time the server starts/restarts. If set to `True`, the records should all equate to `LOGIN_FAILURE_LIMIT`. Default: `True`
* `AXES_LOGIN_FAILURE_LIMIT`: The number of login attempts allowed before a record is created for the failed logins. Default: `3`
* `AXES_LOCK_OUT_AT_FAILURE`: After the number of allowed login attempts are exceeded, should we lock out this IP (and optional user agent)? Default: `True`
* `AXES_USE_USER_AGENT`: If True, lock out / log based on an IP address AND a user agent. This means requests from different user agents but from the same IP are treated differently. Default: `False`
==Usage==
Using `django-axes` is extremely simple. Once you install the application and the middleware, all you need to do is periodically check the Access Attempts section of the admin. A log file is also created for you to keep track of the events surrounding failed login attempts. This log file can be found in your Django project directory, by the name of `axes.log`. In the future I plan on offering a way to customize options for logging a bit more.
Using `django-axes` is extremely simple. Once you install the application and the middleware, all you need to do is periodically check the Access Attempts section of the admin. A log file is also created for you to keep track of the events surrounding failed login attempts. This log file can be found in your Django project directory, by the name of `axes.log`. In the future I plan on offering a way to customize options for logging a bit more.
By default, django-axes will lock out repeated attempts from the same IP address. You can allow this IP to attempt again by deleting the relevant AccessAttempt records in the admin.

View file

@ -1,19 +1,28 @@
from django.conf import settings
from django.contrib.auth import logout
from axes.models import AccessAttempt
from django.http import HttpResponse
import axes
import datetime
import logging
from django.core.cache import cache
# see if the user has overridden the failure limit
if hasattr(settings, 'LOGIN_FAILURE_LIMIT'):
if hasattr(settings, 'AXES_LOGIN_FAILURE_LIMIT'):
FAILURE_LIMIT = settings.LOGIN_FAILURE_LIMIT
else:
FAILURE_LIMIT = 3
# see if the user has overridden the failure reset setting
if hasattr(settings, 'LOGIN_FAILURE_RESET'):
FAILURE_RESET = settings.LOGIN_FAILURE_RESET
# see if the user has set axes to lock out logins after failure limit
if hasattr(settings, 'AXES_LOCK_OUT_AT_FAILURE'):
LOCK_OUT_AT_FAILURE = settings.AXES_LOCK_OUT_AT_FAILURE
else:
FAILURE_RESET = True
LOCK_OUT_AT_FAILURE = True
if hasattr(settings, 'AXES_USE_USER_AGENT'):
USE_USER_AGENT = settings.AXES_USE_USER_AGENT
else:
USE_USER_AGENT = False
def query2str(items):
return '\n'.join(['%s=%s' % (k, v) for k,v in items])
@ -22,7 +31,26 @@ log = logging.getLogger('axes.watch_login')
log.info('BEGIN LOG')
log.info('Using django-axes ' + axes.get_version())
def watch_login(func, failures):
def get_user_attempt(request):
"""
Returns access attempt record if it exists.
Otherwise return None.
"""
ip = request.META.get('REMOTE_ADDR', '')
if USE_USER_AGENT:
ua = request.META.get('HTTP_USER_AGENT', '<unknown>')
attempts = AccessAttempt.objects.filter(
user_agent=ua,
ip_address=ip
)
else:
attempts = AccessAttempt.objects.filter(
ip_address=ip
)
if attempts:
return attempts[0]
def watch_login(func):
"""
Used to decorate the django.contrib.admin.site.login method.
"""
@ -45,36 +73,50 @@ def watch_login(func, failures):
# failed attempts each supposedly)
return response
# only check when there's been an HTTP POST
if request.method == 'POST':
failures = 0
# see if the login was successful
if response and not response.has_header('location') and response.status_code != 302:
log.debug('Failure dict (begin): %s' % failures)
ip = request.META.get('REMOTE_ADDR', '')
ua = request.META.get('HTTP_USER_AGENT', '<unknown>')
key = '%s:%s' % (ip, ua)
# make sure we have an item for this key
try:
failures[key]
log.debug('Key %s exists' % key)
except KeyError:
log.debug('Creating key %s' % key)
failures[key] = 0
login_unsuccessful = (
response and
not response.has_header('location') and
response.status_code != 302
)
attempt = get_user_attempt(request)
if attempt:
failures = attempt.failures_since_start
if login_unsuccessful:
# add a failed attempt for this user
failures[key] += 1
failures += 1
log.info('-' * 79)
log.info('Adding a failure for %s; %i failure(s)' % (key, failures[key]))
#log.debug('Request: %s' % request)
# if we reach or surpass the failure limit, create an
# AccessAttempt record
if failures[key] >= FAILURE_LIMIT:
# Create an AccessAttempt record if the login wasn't successful
if login_unsuccessful:
# has already attempted, update the info
if attempt:
log.info('=================================')
log.info('Updating access attempt record...')
log.info('=================================')
attempt.get_data = '%s\n---------\n%s' % (
attempt.get_data,
query2str(request.GET.items()),
)
attempt.post_data = '%s\n---------\n%s' % (
attempt.post_data,
query2str(request.POST.items())
)
attempt.http_accept = request.META.get('HTTP_ACCEPT', '<unknown>')
attempt.path_info = request.META.get('PATH_INFO', '<unknown>')
attempt.failures_since_start = failures
attempt.attempt_time = datetime.datetime.now()
attempt.save()
else:
log.info('=================================')
log.info('Creating access attempt record...')
log.info('=================================')
ip = request.META.get('REMOTE_ADDR', '')
ua = request.META.get('HTTP_USER_AGENT', '<unknown>')
attempt = AccessAttempt.objects.create(
user_agent=ua,
ip_address=ip,
@ -82,14 +124,19 @@ def watch_login(func, failures):
post_data=query2str(request.POST.items()),
http_accept=request.META.get('HTTP_ACCEPT', '<unknown>'),
path_info=request.META.get('PATH_INFO', '<unknown>'),
failures_since_start=failures[key]
failures_since_start=failures
)
if FAILURE_RESET:
del(failures[key])
log.debug('Failure dict (end): %s' % failures)
log.info('-' * 79)
# no matter what, we want to lock them out
# if they're past the number of attempts allowed
if failures > FAILURE_LIMIT:
if LOCK_OUT_AT_FAILURE:
response = HttpResponse("Account locked: too many login attempts. "
"Contact an admin to unlock your account."
)
# We log them out in case they actually managed to enter
# the correct password.
logout(request)
return response
return decorated_login
return decorated_login

View file

@ -3,13 +3,12 @@ from django.contrib.auth import views as auth_views
from axes.decorators import watch_login
class FailedLoginMiddleware(object):
failures = {}
def __init__(self, *args, **kwargs):
super(FailedLoginMiddleware, self).__init__(*args, **kwargs)
# watch the admin login page
admin.site.login = watch_login(admin.site.login, self.failures)
admin.site.login = watch_login(admin.site.login)
# and the regular auth login page
auth_views.login = watch_login(auth_views.login, self.failures)
auth_views.login = watch_login(auth_views.login)

View file

@ -1,10 +1,11 @@
from django.db import models
from django.conf import settings
if hasattr(settings, 'LOGIN_FAILURE_RESET'):
FAILURES_DESC = 'Failed Logins Since Server Started'
else:
FAILURES_DESC = 'Failed Logins'
FAILURES_DESC = 'Failed Logins'
#XXX TODO
# set unique by user_agent, ip
# make user agent, ip indexed fields
class AccessAttempt(models.Model):
user_agent = models.CharField(max_length=255)
@ -23,4 +24,4 @@ class AccessAttempt(models.Model):
return self.failures_since_start
class Meta:
ordering = ['-attempt_time']
ordering = ['-attempt_time']

66
axes/tests.py Normal file
View file

@ -0,0 +1,66 @@
from django.test import TestCase, Client
from django.conf import settings
from django.contrib import admin
import random
from django.contrib.auth.models import User
from models import AccessAttempt
from decorators import FAILURE_LIMIT
# Only run tests if they have axes in middleware
# Basically a functional test
class AccessAttemptTest(TestCase):
NOT_GONNA_BE_PASSWORD = "sfdlermmvnLsefrlg0c9gjjPxmvLlkdf2#"
NOT_GONNA_BE_USERNAME = "whywouldyouohwhy"
def setUp(self):
for i in range(0, random.randrange(10, 50)):
username = "person%s" % i
email = "%s@example.org" % username
u = User.objects.create_user(email=email, username=username)
u.is_staff = True
u.save()
def _gen_bad_password(self):
return AccessAttemptTest.NOT_GONNA_BE_PASSWORD + str(random.random())
def _random_username(self, correct_username=False):
if not correct_username:
return (AccessAttemptTest.NOT_GONNA_BE_USERNAME +
str(random.random()))[:30]
else:
return random.choice(User.objects.filter(is_staff=True))
def _attempt_login(self, correct_username=False, user=""):
response = self.client.post(
'/admin/', {'username': self._random_username(correct_username),
'password': self._gen_bad_password()}
)
return response
def test_login_max(self, correct_username=False):
for i in range(0, FAILURE_LIMIT):
response = self._attempt_login(correct_username=correct_username)
self.assertContains(response, "this_is_the_login_form")
# So, we shouldn't have gotten a lock-out yet.
# But we should get one now
response = self._attempt_login()
self.assertContains(response, "Account locked")
def test_login_max_with_more(self, correct_username=False):
for i in range(0, FAILURE_LIMIT):
response = self._attempt_login(correct_username=correct_username)
self.assertContains(response, "this_is_the_login_form")
# So, we shouldn't have gotten a lock-out yet.
# But we should get one now
for i in range(0, random.randrange(1, 100)):
# try to log in a bunch of times
response = self._attempt_login()
self.assertContains(response, "Account locked")
def test_with_real_username_max(self):
self.test_login_max(correct_username=True)
def test_with_real_username_max_with_more(self):
self.test_login_max_with_more(correct_username=True)