diff --git a/.coveragerc b/.coveragerc index cd0537e..52aa906 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,2 @@ [run] -omit = *_settings.py, defender/*migrations/* +omit = *_settings.py, defender/*migrations/*, defender/exampleapp/* diff --git a/.gitignore b/.gitignore index db4561e..2e3ea0c 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,7 @@ docs/_build/ # PyBuilder target/ + +# exampleapp +defender/exampleapp/static/ +defender/exampleapp/media/ diff --git a/.landscape.yaml b/.landscape.yaml index ee0d355..83151ce 100644 --- a/.landscape.yaml +++ b/.landscape.yaml @@ -8,3 +8,4 @@ uses: autodetect: yes ignore-patterns: - .*_settings.py$ + - defender/exampleapp/* diff --git a/README.md b/README.md index b287f50..601f4bb 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ django-defender =============== -A simple django reusable app that blocks people from brute forcing login +A simple Django reusable app that blocks people from brute forcing login attempts. The goal is to make this as fast as possible, so that we do not slow down the login attempts. @@ -18,6 +18,10 @@ Build status [![Build Status](https://travis-ci.org/kencochrane/django-defender.svg)](https://travis-ci.org/kencochrane/django-defender) [![Coverage Status](https://img.shields.io/coveralls/kencochrane/django-defender.svg)](https://coveralls.io/r/kencochrane/django-defender)[![Code Health](https://landscape.io/github/kencochrane/django-defender/master/landscape.svg)](https://landscape.io/github/kencochrane/django-defender/master) +Sites using Defender: +===================== +- https://hub.docker.com + Goals for 0.1 ============= @@ -129,63 +133,40 @@ How it works 1. When someone tries to login, we first check to see if they are currently blocked. We check the username they are trying to use, as well as the IP -address. If they are blocked, goto step 10. If not blocked go to step 2. +address. If they are blocked, goto step 5. If not blocked go to step 2. 2. They are not blocked, so we check to see if the login was valid. If valid -go to step 20. If not valid go to step 3. +go to step 6. If not valid go to step 3. 3. Login attempt wasn't valid. Add their username and IP address for this attempt to the cache. If this brings them over the limit, add them to the -blocked list, and then goto step 10. If not over the limit goto step 4. +blocked list, and then goto step 5. If not over the limit goto step 4. 4. login was invalid, but not over the limit. Send them back to the login screen to try again. -10. User is blocked: Send them to the blocked page, telling them they are +5. User is blocked: Send them to the blocked page, telling them they are blocked, and give an estimate on when they will be unblocked. -20. Login is valid. Reset any failed login attempts, and forward to their +6. Login is valid. Reset any failed login attempts, and forward to their destination. Cache backend: ============== -- ip_attempts (count, TTL) -- username_attempts (count, TTL) -- ip_blocks (list) -- username_blocks (list) - cache keys: ----------- +Counters: - prefix:failed:ip:[ip] (count, TTL) - prefix:failed:username:[username] (count, TTL) + +Booleans (if present it is blocked): - prefix:blocked:ip:[ip] (true, TTL) - prefix:blocked:username:[username] (true, TTL) -Rate limiting Example ---------------------- -``` -# example of how to do rate limiting by IP -# assuming it is 10 requests being the limit -# this assumes there is a DECAY of DECAY_TIME -# to remove invalid logins after a set number of time -# For every incorrect login, we reset the block time. - -FUNCTION LIMIT_API_CALL(ip) -current = LLEN(ip) -IF current > 10 THEN - ERROR "too many requests per second" -ELSE - MULTI - RPUSH(ip, ip) - EXPIRE(ip, DECAY_TIME) - EXEC -END -``` - Installing Django-defender ========================== @@ -229,6 +210,25 @@ Next, install the ``FailedLoginMiddleware`` middleware:: ) ``` +If you want to manage the blocked users via the Django admin, then add the +following to your ``urls.py`` + +``` +urlpatterns = patterns( + '', + (r'^admin/', include(admin.site.urls)), # normal admin + (r'^admin/defender/', include('defender.urls')), # defender admin + # your own patterns follow… +) +``` + + +Admin Pages: +------------ +![alt tag](https://cloud.githubusercontent.com/assets/261601/5950540/8895b570-a729-11e4-9dc3-6b00e46c8043.png) + +![alt tag](https://cloud.githubusercontent.com/assets/261601/5950541/88a35194-a729-11e4-981b-3a55b44ef9d5.png) + Database tables: ---------------- @@ -257,30 +257,31 @@ You have a couple options available to you to customize ``django-defender`` a bi These should be defined in your ``settings.py`` file. * ``DEFENDER_LOGIN_FAILURE_LIMIT``: Int: The number of login attempts allowed before a -record is created for the failed logins. Default: ``3`` +record is created for the failed logins. [Default: ``3``] * ``DEFENDER_USE_USER_AGENT``: Boolean: 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`` +the same IP are treated differently. [Default: ``False``] * ``DEFENDER_COOLOFF_TIME``: Int: If set, defines a period of inactivity after which old failed login attempts will be forgotten. An integer, will be interpreted as a -number of seconds. If ``0``, the locks will not expire. Default: ``300`` -* ``DEFENDER_LOCKOUT_TEMPLATE``: String: If set, specifies a template to render when a -user is locked out. Template receives cooloff_time and failure_limit as -context variables. Default: ``None`` +number of seconds. If ``0``, the locks will not expire. [Default: ``300``] +* ``DEFENDER_LOCKOUT_TEMPLATE``: String: [Default: ``None``] If set, specifies a template to render when a user is locked out. Template receives the following context variables: + - ``cooloff_time_seconds``: The cool off time in seconds + - ``cooloff_time_minutes``: The cool off time in minutes + - ``failure_limit``: The number of failures before you get blocked. * ``DEFENDER_USERNAME_FORM_FIELD``: String: the name of the form field that contains your -users usernames. Default: ``username`` +users usernames. [Default: ``username``] * ``DEFENDER_REVERSE_PROXY_HEADER``: String: the name of the http header with your -reverse proxy IP address Default: ``HTTP_X_FORWARDED_FOR`` +reverse proxy IP address [Default: ``HTTP_X_FORWARDED_FOR``] * ``DEFENDER_CACHE_PREFIX``: String: The cache prefix for your defender keys. -Default: ``defender`` +[Default: ``defender``] * ``DEFENDER_LOCKOUT_URL``: String: The URL you want to redirect to if someone is locked out. * ``DEFENDER_REDIS_URL``: String: the redis url for defender. -Default: ``redis://localhost:6379/0`` +[Default: ``redis://localhost:6379/0``] (Example with password: ``redis://:mypassword@localhost:6379/0``) * ``DEFENDER_USE_CELERY``: Boolean: If you want to use Celery to store the login attempt to the database, set to True. If False, it is saved inline. -Default: ``False`` +[Default: ``False``] Running Tests ============= diff --git a/defender/admin.py b/defender/admin.py index ccfaa8e..491c295 100644 --- a/defender/admin.py +++ b/defender/admin.py @@ -1,5 +1,4 @@ from django.contrib import admin - from .models import AccessAttempt @@ -33,12 +32,10 @@ class AccessAttemptAdmin(admin.ModelAdmin): (None, { 'fields': ('path_info', 'login_valid') }), - ('Form Data', { - 'fields': ('get_data', 'post_data') - }), ('Meta Data', { - 'fields': ('user_agent', 'ip_address', 'http_accept') + 'fields': ('user_agent', 'ip_address') }) ) + admin.site.register(AccessAttempt, AccessAttemptAdmin) diff --git a/defender/exampleapp/__init__.py b/defender/exampleapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/defender/exampleapp/defender.sb b/defender/exampleapp/defender.sb new file mode 100644 index 0000000..604e87b Binary files /dev/null and b/defender/exampleapp/defender.sb differ diff --git a/defender/exampleapp/readme.md b/defender/exampleapp/readme.md new file mode 100644 index 0000000..ba61ac1 --- /dev/null +++ b/defender/exampleapp/readme.md @@ -0,0 +1,14 @@ +Example App +=========== + +admin password is ``admin:password`` + + +This is just a simple example app, used for testing and showing how things work +``` +mkdir -p exampleapp/static exampleapp/media/static + +PYTHONPATH=$PYTHONPATH:$PWD django-admin.py collectstatic --noinput --settings=defender.exampleapp.settings + +PYTHONPATH=$PYTHONPATH:$PWD django-admin.py runserver --settings=defender.exampleapp.settings +``` diff --git a/defender/exampleapp/settings.py b/defender/exampleapp/settings.py new file mode 100644 index 0000000..d331af7 --- /dev/null +++ b/defender/exampleapp/settings.py @@ -0,0 +1,81 @@ +import os +PROJECT_DIR = lambda base: os.path.abspath( + os.path.join(os.path.dirname(__file__), base).replace('\\', '/')) + + +MEDIA_ROOT = PROJECT_DIR(os.path.join('media')) +MEDIA_URL = '/media/' +STATIC_ROOT = PROJECT_DIR(os.path.join('static')) +STATIC_URL = '/static/' + +STATICFILES_DIRS = ( + PROJECT_DIR(os.path.join('media', 'static')), +) + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': PROJECT_DIR('defender.sb'), + } +} + + +SITE_ID = 1 + +MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'defender.middleware.FailedLoginMiddleware', +) + +ROOT_URLCONF = 'defender.exampleapp.urls' + +INSTALLED_APPS = [ + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + 'django.contrib.admin', + 'django.contrib.staticfiles', + 'defender', +] + +# List of finder classes that know how to find static files in +# various locations. +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', +) + +SECRET_KEY = os.environ.get('SECRET_KEY', 'too-secret-for-test') + +LOGIN_REDIRECT_URL = '/admin' + +DEFENDER_LOGIN_FAILURE_LIMIT = 1 +DEFENDER_COOLOFF_TIME = 60 +DEFENDER_REDIS_URL = "redis://localhost:6379/1" +# don't use mock redis in unit tests, we will use real redis on travis. +DEFENDER_MOCK_REDIS = False + +# Celery settings: +CELERY_ALWAYS_EAGER = True +BROKER_BACKEND = 'memory' +BROKER_URL = 'memory://' + +import os + +from celery import Celery + +# set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'defender.exampleapp.settings') + +app = Celery('defender') + +# Using a string here means the worker will not have to +# pickle the object when using Windows. +app.config_from_object('django.conf:settings') +app.autodiscover_tasks(lambda: INSTALLED_APPS) + +DEBUG = True diff --git a/defender/exampleapp/urls.py b/defender/exampleapp/urls.py new file mode 100644 index 0000000..c0d8596 --- /dev/null +++ b/defender/exampleapp/urls.py @@ -0,0 +1,17 @@ +from django.conf.urls import patterns, include +from django.conf import settings +from django.contrib import admin +from django.contrib.staticfiles.urls import staticfiles_urlpatterns +from django.conf.urls.static import static + +admin.autodiscover() + +urlpatterns = patterns( + '', + (r'^admin/', include(admin.site.urls)), + (r'^admin/defender/', include('defender.urls')), +) + + +urlpatterns += staticfiles_urlpatterns() +urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/defender/templates/admin/defender/app_index.html b/defender/templates/admin/defender/app_index.html new file mode 100644 index 0000000..ccf8914 --- /dev/null +++ b/defender/templates/admin/defender/app_index.html @@ -0,0 +1,25 @@ +{% extends "admin/index.html" %} +{% load i18n %} + +{% if not is_popup %} +{% block breadcrumbs %} + +{% endblock %} +{% endif %} + +{% block sidebar %}{% endblock %} + +{% block content %} +{{ block.super }} +
+
+

Blocked Users

+
+
+{% endblock content%} diff --git a/defender/templates/defender/admin/blocks.html b/defender/templates/defender/admin/blocks.html new file mode 100644 index 0000000..5bda17e --- /dev/null +++ b/defender/templates/defender/admin/blocks.html @@ -0,0 +1,74 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls %} + +{% block bodyclass %}dashboard{% endblock %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock breadcrumbs %} + +{% block content %} +
+ +

Blocked Logins

+

Here is a list of IP's and usernames that are blocked

+ +
+ + + + + + + + {% for block in blocked_ip_list %} + + + + + {% empty %} + + {% endfor %} + +
Blocked IP's
IPAction
{{block}} +
+ {% csrf_token %} + +
+
No IP's
+
+ +
+ + + + + + + {% for block in blocked_username_list %} + + + + + {% empty %} + + {% endfor %} + +
Blocked Usernames
UsernamesAction
{{block}} +
+ {% csrf_token %} + +
+
No Username's
+ +
+
+{% endblock content %} diff --git a/defender/templates/defender/lockout.html b/defender/templates/defender/lockout.html index d84ec21..a365513 100644 --- a/defender/templates/defender/lockout.html +++ b/defender/templates/defender/lockout.html @@ -2,6 +2,6 @@

Locked out

Your have attempted to login {{failure_limit}} times, with no success. -Your account is locked for {{cooloff_time}} seconds

+Your account is locked for {{cooloff_time_seconds}} seconds

diff --git a/defender/urls.py b/defender/urls.py new file mode 100644 index 0000000..7d4a61e --- /dev/null +++ b/defender/urls.py @@ -0,0 +1,13 @@ +from django.conf.urls import patterns, url +from views import block_view, unblock_ip_view, unblock_username_view + +urlpatterns = patterns( + '', + url(r'^blocks/$', block_view, + name="defender_blocks_view"), + url(r'^blocks/ip/(?P[a-z0-9-._]+)/unblock$', unblock_ip_view, + name="defender_unblock_ip_view"), + url(r'^blocks/username/(?P[a-z0-9-._@]+)/unblock$', + unblock_username_view, + name="defender_unblock_username_view"), +) diff --git a/defender/utils.py b/defender/utils.py index 0de7ed2..4761dbf 100644 --- a/defender/utils.py +++ b/defender/utils.py @@ -97,6 +97,36 @@ def get_username_blocked_cache_key(username): return "{0}:blocked:username:{1}".format(config.CACHE_PREFIX, username) +def strip_keys(key_list): + """ Given a list of keys, remove the prefix and remove just + the data we care about. + + for example: + + ['defender:blocked:ip:ken', 'defender:blocked:ip:joffrey'] + + would result in: + + ['ken', 'joffrey'] + + """ + return [key.split(":")[-1] for key in key_list] + + +def get_blocked_ips(): + """ get a list of blocked ips from redis """ + key = get_ip_blocked_cache_key("*") + key_list = redis_server.keys(key) + return strip_keys(key_list) + + +def get_blocked_usernames(): + """ get a list of blocked usernames from redis """ + key = get_username_blocked_cache_key("*") + key_list = redis_server.keys(key) + return strip_keys(key_list) + + def increment_key(key): """ given a key increment the value """ pipe = redis_server.pipeline() @@ -163,16 +193,40 @@ def record_failed_attempt(ip, username): return True +def unblock_ip(ip, pipe=None): + """ unblock the given IP """ + do_commit = False + if not pipe: + pipe = redis_server.pipeline() + do_commit = True + if ip: + pipe.delete(get_ip_attempt_cache_key(ip)) + pipe.delete(get_ip_blocked_cache_key(ip)) + if do_commit: + pipe.execute() + + +def unblock_username(username, pipe=None): + """ unblock the given Username """ + do_commit = False + if not pipe: + pipe = redis_server.pipeline() + do_commit = True + if username: + pipe.delete(get_username_attempt_cache_key(username)) + pipe.delete(get_username_blocked_cache_key(username)) + if do_commit: + pipe.execute() + + def reset_failed_attempts(ip=None, username=None): """ reset the failed attempts for these ip's and usernames """ pipe = redis_server.pipeline() - if ip: - pipe.delete(get_ip_attempt_cache_key(ip)) - pipe.delete(get_ip_blocked_cache_key(ip)) - if username: - pipe.delete(get_username_attempt_cache_key(username)) - pipe.delete(get_username_blocked_cache_key(username)) + + unblock_ip(ip, pipe=pipe) + unblock_username(username, pipe=pipe) + pipe.execute() @@ -180,7 +234,8 @@ def lockout_response(request): """ if we are locked out, here is the response """ if config.LOCKOUT_TEMPLATE: context = { - 'cooloff_time': config.COOLOFF_TIME, + 'cooloff_time_seconds': config.COOLOFF_TIME, + 'cooloff_time_minutes': config.COOLOFF_TIME / 60, 'failure_limit': config.FAILURE_LIMIT, } return render_to_response(config.LOCKOUT_TEMPLATE, context, diff --git a/defender/views.py b/defender/views.py index e69de29..e9920fb 100644 --- a/defender/views.py +++ b/defender/views.py @@ -0,0 +1,37 @@ +from django.shortcuts import render_to_response +from django.template import RequestContext +from django.http import HttpResponseRedirect +from django.core.urlresolvers import reverse +from django.contrib.admin.views.decorators import staff_member_required + +from .utils import ( + get_blocked_ips, get_blocked_usernames, unblock_ip, unblock_username) + + +@staff_member_required +def block_view(request): + """ List the blocked IP and Usernames """ + blocked_ip_list = get_blocked_ips() + blocked_username_list = get_blocked_usernames() + + context = {'blocked_ip_list': blocked_ip_list, + 'blocked_username_list': blocked_username_list} + return render_to_response( + 'defender/admin/blocks.html', + context, context_instance=RequestContext(request)) + + +@staff_member_required +def unblock_ip_view(request, ip): + """ upblock the given ip """ + if request.method == 'POST': + unblock_ip(ip) + return HttpResponseRedirect(reverse("defender_blocks_view")) + + +@staff_member_required +def unblock_username_view(request, username): + """ unblockt he given username """ + if request.method == 'POST': + unblock_username(username) + return HttpResponseRedirect(reverse("defender_blocks_view"))