Permalink
Browse files

GC #1 - I've implemented some of the groovy features offered by phili…

…pn. Thanks!
  • Loading branch information...
1 parent 74676db commit eafc81b7d8f3dcb1ef26a6560b238380f8b20efc codekoala committed Dec 17, 2009
Showing with 165 additions and 49 deletions.
  1. +1 −1 LICENSE
  2. +7 −4 README
  3. +83 −36 axes/decorators.py
  4. +2 −3 axes/middleware.py
  5. +6 −5 axes/models.py
  6. +66 −0 axes/tests.py
View
@@ -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
View
11 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.
+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
@@ -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 @@ def query2str(items):
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,51 +73,70 @@ def decorated_login(request, *args, **kwargs):
# 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
-
- log.info('Adding a failure for %s; %i failure(s)' % (key, failures[key]))
- #log.debug('Request: %s' % request)
+ failures += 1
+ log.info('-' * 79)
- # 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,
get_data=query2str(request.GET.items()),
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
@@ -3,13 +3,12 @@
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
@@ -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 @@ def failures(self):
return self.failures_since_start
class Meta:
- ordering = ['-attempt_time']
+ ordering = ['-attempt_time']
View
@@ -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)):
@vanschelven

vanschelven Jun 22, 2012

Using randrange in tests is not a good idea: this (might) introduce(s) non-deterministic behavior into the tests, causing them to fail/succeed unreliably; what you really mean here is "any random number between 10-50", but you might as well choose that random number in the tests.

+ 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)):
@vanschelven

vanschelven Jun 22, 2012

Using randrange in tests is not a good idea: this (might) introduce(s) non-deterministic behavior into the tests, causing them to fail/succeed unreliably; what you really mean here is "any random number between 1-100", but you might as well choose that random number in the tests.

+ # 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)

0 comments on commit eafc81b

Please sign in to comment.