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 e2812ee..332aa43 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. @@ -129,63 +129,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 +206,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: ---------------- diff --git a/defender/admin.py b/defender/admin.py index afdf646..491c295 100644 --- a/defender/admin.py +++ b/defender/admin.py @@ -1,13 +1,5 @@ from django.contrib import admin -from django.conf.urls import patterns, url -from django.shortcuts import render_to_response -from django.template import RequestContext -from django.http import HttpResponseRedirect -from django.core.urlresolvers import reverse - from .models import AccessAttempt -from .utils import ( - get_blocked_ips, get_blocked_usernames, unblock_ip, unblock_username) class AccessAttemptAdmin(admin.ModelAdmin): @@ -40,53 +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') }) ) - def get_urls(self): - """ get the default urls and add ours """ - urls = super(AccessAttemptAdmin, self).get_urls() - my_urls = patterns( - '', - url(r'^blocks/$', - self.admin_site.admin_view(self.block_view), - name="defender_blocks_view"), - url(r'^blocks/ip/(?P\w+)/unblock$', - self.admin_site.admin_view(self.unblock_ip_view), - name="defender_unblock_ip_view"), - url(r'^blocks/username/(?P\w+)/unblock$', - self.admin_site.admin_view(self.unblock_username_view), - name="defender_unblock_username_view"), - ) - return my_urls + urls - - def block_view(self, request): - """ List the blocked IP and Usernames """ - blocked_ip_list = get_blocked_ips() - blocked_username_list = get_blocked_usernames() - - context = {'current_app': self.admin_site.name, - 'blocked_ip_list': blocked_ip_list, - 'blocked_username_list': blocked_username_list} - return render_to_response( - 'admin/defender/blocks.html', - context, context_instance=RequestContext(request)) - - def unblock_ip_view(self, request, ip): - """ upblock the given ip """ - if request.method == 'POST': - unblock_ip(ip) - return HttpResponseRedirect(reverse("defender_blocks_view")) - - def unblock_username_view(self, request, username): - """ unblockt he given username """ - if request.method == 'POST': - unblock_username(username) - return HttpResponseRedirect(reverse("defender_blocks_view")) 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/admin/defender/blocks.html b/defender/templates/admin/defender/blocks.html deleted file mode 100644 index 4305e81..0000000 --- a/defender/templates/admin/defender/blocks.html +++ /dev/null @@ -1,43 +0,0 @@ -{% extends "admin/change_list.html" %} -{% load i18n %} - -{% block result_list %} -

Blocked Logins

-

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

- -

Blocked IP's

- - - {% for block in blocked_ip_list %} - - - - - {% empty %} - - {% endfor %} -
IPAction
{{block}} -
- {% csrf_token %} - -
-
No IP's
- -

Blocked Usernames

- - - {% for block in blocked_username_list %} - - - - - {% empty %} - - {% endfor %} -
UsernameAction
{{block}} -
- {% csrf_token %} - -
-
No Username's
-{% endblock result_list %} 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/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/views.py b/defender/views.py index e69de29..8796518 100644 --- a/defender/views.py +++ b/defender/views.py @@ -0,0 +1,33 @@ +from django.shortcuts import render_to_response +from django.template import RequestContext +from django.http import HttpResponseRedirect +from django.core.urlresolvers import reverse + +from .utils import ( + get_blocked_ips, get_blocked_usernames, unblock_ip, unblock_username) + + +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)) + + +def unblock_ip_view(request, ip): + """ upblock the given ip """ + if request.method == 'POST': + unblock_ip(ip) + return HttpResponseRedirect(reverse("defender_blocks_view")) + + +def unblock_username_view(request, username): + """ unblockt he given username """ + if request.method == 'POST': + unblock_username(username) + return HttpResponseRedirect(reverse("defender_blocks_view"))