diff --git a/LICENSE b/LICENSE index 3095056..632c21d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License -Copyright (c) 2008 Josh VanderLinden +Copyright (c) 2008 Josh VanderLinden, 2009 Philip Neustrom Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README b/README index bea4419..90f55e9 100644 --- a/README +++ b/README @@ -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. \ No newline at end of file +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. diff --git a/axes/decorators.py b/axes/decorators.py index c244c53..a7f3278 100644 --- a/axes/decorators.py +++ b/axes/decorators.py @@ -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', '') + 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', '') - - 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', '') + attempt.path_info = request.META.get('PATH_INFO', '') + 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', '') 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', ''), path_info=request.META.get('PATH_INFO', ''), - 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 \ No newline at end of file + return decorated_login diff --git a/axes/middleware.py b/axes/middleware.py index 9caa4e1..b2d5880 100644 --- a/axes/middleware.py +++ b/axes/middleware.py @@ -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) diff --git a/axes/models.py b/axes/models.py index 859cd35..0c0e942 100644 --- a/axes/models.py +++ b/axes/models.py @@ -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'] \ No newline at end of file + ordering = ['-attempt_time'] diff --git a/axes/tests.py b/axes/tests.py new file mode 100644 index 0000000..0eb8923 --- /dev/null +++ b/axes/tests.py @@ -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)