From db3eea99cc3853e872fedbf1775df0b5c85add9f Mon Sep 17 00:00:00 2001 From: Ken Cochrane Date: Tue, 27 Jan 2015 18:06:56 -0500 Subject: [PATCH 1/5] initial commit, adding admin pages to manage blocked users --- defender/admin.py | 48 ++++++++++++++ defender/templates/admin/defender/blocks.html | 43 ++++++++++++ defender/utils.py | 66 +++++++++++++++++-- 3 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 defender/templates/admin/defender/blocks.html diff --git a/defender/admin.py b/defender/admin.py index ccfaa8e..afdf646 100644 --- a/defender/admin.py +++ b/defender/admin.py @@ -1,6 +1,13 @@ 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): @@ -41,4 +48,45 @@ class AccessAttemptAdmin(admin.ModelAdmin): }) ) + 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/templates/admin/defender/blocks.html b/defender/templates/admin/defender/blocks.html new file mode 100644 index 0000000..4305e81 --- /dev/null +++ b/defender/templates/admin/defender/blocks.html @@ -0,0 +1,43 @@ +{% 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/utils.py b/defender/utils.py index 0de7ed2..cad9dac 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() From 12698d7d54a12b2ad250017a4c81b5a4e4d91f3d Mon Sep 17 00:00:00 2001 From: Ken Cochrane Date: Wed, 28 Jan 2015 20:19:16 -0500 Subject: [PATCH 2/5] finished working on the defender admin, cleaned some stuff up, added some notes and screenshots --- .coveragerc | 2 +- .gitignore | 4 + .landscape.yaml | 1 + README.md | 60 ++++++------- defender/admin.py | 53 +----------- defender/exampleapp/__init__.py | 0 defender/exampleapp/defender.sb | Bin 0 -> 139264 bytes defender/exampleapp/readme.md | 14 +++ defender/exampleapp/settings.py | 81 ++++++++++++++++++ defender/exampleapp/urls.py | 17 ++++ .../templates/admin/defender/app_index.html | 25 ++++++ defender/templates/admin/defender/blocks.html | 43 ---------- defender/templates/defender/admin/blocks.html | 74 ++++++++++++++++ defender/urls.py | 13 +++ defender/views.py | 33 +++++++ 15 files changed, 292 insertions(+), 128 deletions(-) create mode 100644 defender/exampleapp/__init__.py create mode 100644 defender/exampleapp/defender.sb create mode 100644 defender/exampleapp/readme.md create mode 100644 defender/exampleapp/settings.py create mode 100644 defender/exampleapp/urls.py create mode 100644 defender/templates/admin/defender/app_index.html delete mode 100644 defender/templates/admin/defender/blocks.html create mode 100644 defender/templates/defender/admin/blocks.html create mode 100644 defender/urls.py 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 0000000000000000000000000000000000000000..604e87b008b60f0947b17e9bb9a3cdfd2ebfe938 GIT binary patch literal 139264 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 literal 0 HcmV?d00001 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")) From 130f696a7e3cd37c5cd0c5a95434408f520e8840 Mon Sep 17 00:00:00 2001 From: Ken Cochrane Date: Thu, 29 Jan 2015 08:10:19 -0500 Subject: [PATCH 3/5] added new context variable to lockout template --- README.md | 27 ++++++++++++++++----------- defender/utils.py | 3 ++- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5a227a4..601f4bb 100644 --- a/README.md +++ b/README.md @@ -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 ============= @@ -253,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/utils.py b/defender/utils.py index cad9dac..4761dbf 100644 --- a/defender/utils.py +++ b/defender/utils.py @@ -234,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, From d0a23a71baa41a48969e031052332322bf80e1e0 Mon Sep 17 00:00:00 2001 From: Ken Cochrane Date: Thu, 29 Jan 2015 08:15:38 -0500 Subject: [PATCH 4/5] updated lockout.html template --- defender/templates/defender/lockout.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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

From 389ae2d91fb87220116845acfc0bdfd0dc43bab7 Mon Sep 17 00:00:00 2001 From: Ken Cochrane Date: Thu, 29 Jan 2015 08:22:29 -0500 Subject: [PATCH 5/5] locked down the defender views --- defender/views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/defender/views.py b/defender/views.py index 8796518..e9920fb 100644 --- a/defender/views.py +++ b/defender/views.py @@ -2,11 +2,13 @@ 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() @@ -19,6 +21,7 @@ def block_view(request): context, context_instance=RequestContext(request)) +@staff_member_required def unblock_ip_view(request, ip): """ upblock the given ip """ if request.method == 'POST': @@ -26,6 +29,7 @@ def unblock_ip_view(request, ip): return HttpResponseRedirect(reverse("defender_blocks_view")) +@staff_member_required def unblock_username_view(request, username): """ unblockt he given username """ if request.method == 'POST':