diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3095056 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2008 Josh VanderLinden + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/MANIFEST b/MANIFEST new file mode 100644 index 0000000..3b2ab43 --- /dev/null +++ b/MANIFEST @@ -0,0 +1,8 @@ +README +setup.py +axes/__init__.py +axes/admin.py +axes/decorators.py +axes/middleware.py +axes/models.py +axes/views.py diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..dba3732 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +recursive-include axes *.py diff --git a/README b/README new file mode 100644 index 0000000..75c471a --- /dev/null +++ b/README @@ -0,0 +1,82 @@ +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. + +==Requirements== + +`django-axes` requires Django 1.0 or later. The application is intended to work around the Django admin and the regular `django.contrib.auth` login-powered pages. + +==Installation== + +Download `django-axes` using *one* of the following methods: + +===easy_install=== + +You can download the package from the [http://pypi.python.org/pypi/django-axes/ CheeseShop] or use + +{{{ +easy_install django-axes +}}} + +to download and install `django-axes`. + +===Package Download=== + +Download the latest `.tar.gz` file from the downloads section and extract it somewhere you'll remember. Use `python setup.py install` to install it. + +===Checkout from Subversion=== + +Execute the following command (or use the equivalent function in a GUI such as TortoiseSVN), and make sure you're checking `django-axes` out somewhere on the `PYTHONPATH`. + +{{{ +svn co http://django-axes.googlecode.com/svn/trunk/axes axes +}}} + +===Verifying Installation=== + +The easiest way to ensure that you have successfully installed `django-axes` is to execute a command such as: + +{{{ +python -c "import axes; print axes.get_version()" +}}} + +If that command completes with some sort of version number, you're probably good to go. If you see error outout, you need to check your installation (I'd start with your `PYTHONPATH`). + +==Configuration== + +First of all, you must add this project to your list of `INSTALLED_APPS` in `settings.py`: + +{{{ +INSTALLED_APPS = ( + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + ... + 'axes', + ... +) +}}} + +Next, install the `FailedLoginMiddleware` middleware: + +{{{ +MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'axes.middleware.FailedLoginMiddleware' +) +}}} + +Run `manage.py syncdb`. This creates the appropriate tables in your database that are necessary for operation. + +===Customizing Axes=== + +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` + +==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. \ No newline at end of file diff --git a/axes/__init__.py b/axes/__init__.py new file mode 100644 index 0000000..df00547 --- /dev/null +++ b/axes/__init__.py @@ -0,0 +1,4 @@ +VERSION = (0, 1, 0, 'pre') + +def get_version(): + return '%s.%s.%s-%s' % VERSION \ No newline at end of file diff --git a/axes/admin.py b/axes/admin.py new file mode 100644 index 0000000..dc623b7 --- /dev/null +++ b/axes/admin.py @@ -0,0 +1,21 @@ +from django.contrib import admin +from axes.models import AccessAttempt + +class AccessAttemptAdmin(admin.ModelAdmin): + list_display = ('attempt_time', 'ip_address', 'user_agent', 'path_info', 'failures_since_start') + list_filter = ['attempt_time', 'ip_address', 'path_info'] + search_fields = ['ip_address', 'user_agent', 'path_info'] + date_hierarchy = 'attempt_time' + fieldsets = ( + (None, { + 'fields': ('path_info', 'failures_since_start') + }), + ('Form Data', { + 'fields': ('get_data', 'post_data') + }), + ('Meta Data', { + 'fields': ('user_agent', 'ip_address', 'http_accept') + }) + ) + +admin.site.register(AccessAttempt, AccessAttemptAdmin) \ No newline at end of file diff --git a/axes/decorators.py b/axes/decorators.py new file mode 100644 index 0000000..3d86e60 --- /dev/null +++ b/axes/decorators.py @@ -0,0 +1,65 @@ +from axes.models import AccessAttempt +from django.conf import settings + +# see if the user has overridden the failure limit +if hasattr(settings, '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 +else: + FAILURE_RESET = True + +def query2str(items): + return '\n'.join(['%s=%s' % (k, v) for k,v in items]) + +def watch_login(func, failures): + """ + Used to decorate the django.contrib.admin.site.login method. + """ + + def new(*args, **kwargs): + request = args[0] + + # call the login function + response = func(*args, **kwargs) + + # only check when there's been an HTTP POST + if request.method == 'POST': + # see if the login was successful + if not response.has_header('location') and response.status_code != 302: + 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] + except KeyError: + failures[key] = 0 + + # add a failed attempt for this user + failures[key] += 1 + + # if we reach or surpass the failure limit, create an + # AccessAttempt record + if failures[key] >= FAILURE_LIMIT: + 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', ''), + path_info=request.META.get('PATH_INFO', ''), + failures_since_start=failures[key] + ) + + if FAILURE_RESET: + del(failures[key]) + + return response + return new \ No newline at end of file diff --git a/axes/middleware.py b/axes/middleware.py new file mode 100644 index 0000000..9caa4e1 --- /dev/null +++ b/axes/middleware.py @@ -0,0 +1,15 @@ +from django.contrib import admin +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) + + # and the regular auth login page + auth_views.login = watch_login(auth_views.login, self.failures) diff --git a/axes/models.py b/axes/models.py new file mode 100644 index 0000000..859cd35 --- /dev/null +++ b/axes/models.py @@ -0,0 +1,26 @@ +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' + +class AccessAttempt(models.Model): + user_agent = models.CharField(max_length=255) + ip_address = models.IPAddressField('IP Address') + get_data = models.TextField('GET Data') + post_data = models.TextField('POST Data') + http_accept = models.CharField('HTTP Accept', max_length=255) + path_info = models.CharField('Path', max_length=255) + failures_since_start = models.PositiveIntegerField(FAILURES_DESC) + attempt_time = models.DateTimeField(auto_now_add=True) + + def __unicode__(self): + return u'Attempted Access: %s' % self.attempt_time + + def failures(self): + return self.failures_since_start + + class Meta: + ordering = ['-attempt_time'] \ No newline at end of file diff --git a/axes/views.py b/axes/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/axes/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/dist/django-axes-0.1.0-pre.tar.bz2 b/dist/django-axes-0.1.0-pre.tar.bz2 new file mode 100644 index 0000000..9cc15a6 Binary files /dev/null and b/dist/django-axes-0.1.0-pre.tar.bz2 differ diff --git a/dist/django-axes-0.1.0-pre.tar.bz2.asc b/dist/django-axes-0.1.0-pre.tar.bz2.asc new file mode 100644 index 0000000..0326fc5 --- /dev/null +++ b/dist/django-axes-0.1.0-pre.tar.bz2.asc @@ -0,0 +1,7 @@ +-----BEGIN PGP SIGNATURE----- +Version: GnuPG v1.4.9 (GNU/Linux) + +iEYEABECAAYFAkkR1MwACgkQPhnaFjTndGC6EQCcCrDZzQJEoimzIqTQsGg11jhY +QE8An0i99SMVgw3jWu2nU978cqkJ8Bm9 +=iW9f +-----END PGP SIGNATURE----- diff --git a/dist/django-axes-0.1.0-pre.tar.gz b/dist/django-axes-0.1.0-pre.tar.gz new file mode 100644 index 0000000..ecb51c5 Binary files /dev/null and b/dist/django-axes-0.1.0-pre.tar.gz differ diff --git a/dist/django-axes-0.1.0-pre.tar.gz.asc b/dist/django-axes-0.1.0-pre.tar.gz.asc new file mode 100644 index 0000000..82b8052 --- /dev/null +++ b/dist/django-axes-0.1.0-pre.tar.gz.asc @@ -0,0 +1,7 @@ +-----BEGIN PGP SIGNATURE----- +Version: GnuPG v1.4.9 (GNU/Linux) + +iEYEABECAAYFAkkR1MgACgkQPhnaFjTndGCh0QCg38Qloko0PfyMslD8lm7u3QGV +E6IAn2WO1MN8NUJ/rSNSIJsAZBlySWfO +=QQ0n +-----END PGP SIGNATURE----- diff --git a/dist/django-axes-0.1.0-pre.zip b/dist/django-axes-0.1.0-pre.zip new file mode 100644 index 0000000..207f955 Binary files /dev/null and b/dist/django-axes-0.1.0-pre.zip differ diff --git a/dist/django-axes-0.1.0-pre.zip.asc b/dist/django-axes-0.1.0-pre.zip.asc new file mode 100644 index 0000000..0f13f5c --- /dev/null +++ b/dist/django-axes-0.1.0-pre.zip.asc @@ -0,0 +1,7 @@ +-----BEGIN PGP SIGNATURE----- +Version: GnuPG v1.4.9 (GNU/Linux) + +iEYEABECAAYFAkkR1NAACgkQPhnaFjTndGClvACfR7PB7XIF0tQr1yVEciZSsv5f +GEMAoLpWw3nR82wsNrT5awDL3TfDy6xt +=b0EF +-----END PGP SIGNATURE----- diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..9576848 --- /dev/null +++ b/setup.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from distutils.core import setup +import axes +import sys, os + +def fullsplit(path, result=None): + """ + Split a pathname into components (the opposite of os.path.join) in a + platform-neutral way. + """ + if result is None: + result = [] + head, tail = os.path.split(path) + if head == '': + return [tail] + result + if head == path: + return result + return fullsplit(head, [tail] + result) + +packages, data_files = [], [] +root_dir = os.path.dirname(__file__) +if root_dir != '': + os.chdir(root_dir) +axes_dir = 'axes' + +for path, dirs, files in os.walk(axes_dir): + # ignore hidden directories and files + for i, d in enumerate(dirs): + if d.startswith('.'): del dirs[i] + + if '__init__.py' in files: + packages.append('.'.join(fullsplit(path))) + elif files: + data_files.append((path, [os.path.join(path, f) for f in files])) + +setup( + name='django-axes', + version=axes.get_version(), + url='http://code.google.com/p/django-axes/', + author='Josh VanderLinden', + author_email='codekoala@gmail.com', + license='MIT', + packages=packages, + data_files=data_files, + description="Keep track of failed login attempts in Django-powered sites.", + long_description=""" +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. +""", + keywords='django, security, authentication', + classifiers=[ + 'Development Status :: 4 - Beta', + 'Environment :: Web Environment', + 'Framework :: Django', + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Topic :: Internet :: Log Analysis', + 'Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware', + 'Topic :: Security', + 'Topic :: System :: Logging', + ] +)