Merge pull request #25 from kencochrane/new-admin

initial commit, adding admin pages to manage blocked users
This commit is contained in:
Ken Cochrane 2015-01-29 08:39:14 -05:00
commit 58daedcd05
16 changed files with 376 additions and 57 deletions

View file

@ -1,2 +1,2 @@
[run]
omit = *_settings.py, defender/*migrations/*
omit = *_settings.py, defender/*migrations/*, defender/exampleapp/*

4
.gitignore vendored
View file

@ -52,3 +52,7 @@ docs/_build/
# PyBuilder
target/
# exampleapp
defender/exampleapp/static/
defender/exampleapp/media/

View file

@ -8,3 +8,4 @@ uses:
autodetect: yes
ignore-patterns:
- .*_settings.py$
- defender/exampleapp/*

View file

@ -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
=============

View file

@ -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)

View file

Binary file not shown.

View file

@ -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
```

View file

@ -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

View file

@ -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)

View file

@ -0,0 +1,25 @@
{% extends "admin/index.html" %}
{% load i18n %}
{% if not is_popup %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo;
{% for app in app_list %}
{{ app.name }}
{% endfor %}
</div>
{% endblock %}
{% endif %}
{% block sidebar %}{% endblock %}
{% block content %}
{{ block.super }}
<div class="app-defender module">
<table><tr scope='row'><td colspan='3'>
<h4><a href='{% url 'defender_blocks_view' %}'>Blocked Users</a></h4>
</td></tr></table>
</div>
{% endblock content%}

View file

@ -0,0 +1,74 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls %}
{% block bodyclass %}dashboard{% endblock %}
{% block extrastyle %}
{{ block.super }}
<style>table {width: 100%;}</style>
{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url "admin:index" %}">Home</a> &rsaquo;
<a href="{% url "admin:app_list" "defender" %}">Defender</a> &rsaquo;
</div>
{% endblock breadcrumbs %}
{% block content %}
<div id="content-main">
<h1>Blocked Logins</h1>
<p>Here is a list of IP's and usernames that are blocked</p>
<div class="module">
<table>
<caption>Blocked IP's</caption>
<thead>
<tr><th>IP</th><th>Action</th></tr>
</thead>
<tbody>
{% for block in blocked_ip_list %}
<tr class="{% cycle "row2" "row1" %}">
<td>{{block}}</td>
<td>
<form method='POST' action="{% url 'defender_unblock_ip_view' block %}">
{% csrf_token %}
<input type='submit' value='unblock' />
</form>
</td>
</tr>
{% empty %}
<tr><td colspan='2'>No IP's</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="module">
<table>
<caption>Blocked Usernames</caption>
<thead>
<tr><th>Usernames</th><th>Action</th></tr>
</thead>
<tbody>
{% for block in blocked_username_list %}
<tr class="{% cycle "row2" "row1" %}">
<td>{{block}}</td>
<td>
<form method='POST' action="{% url 'defender_unblock_username_view' block %}">
{% csrf_token %}
<input type='submit' value='unblock' />
</form>
</td>
</tr>
{% empty %}
<tr><td colspan='2'>No Username's</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock content %}

View file

@ -2,6 +2,6 @@
<body>
<h1>Locked out</h1>
<p>Your have attempted to login {{failure_limit}} times, with no success.
Your account is locked for {{cooloff_time}} seconds</p>
Your account is locked for {{cooloff_time_seconds}} seconds</p>
</body>
</html>

13
defender/urls.py Normal file
View file

@ -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<ip>[a-z0-9-._]+)/unblock$', unblock_ip_view,
name="defender_unblock_ip_view"),
url(r'^blocks/username/(?P<username>[a-z0-9-._@]+)/unblock$',
unblock_username_view,
name="defender_unblock_username_view"),
)

View file

@ -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,

View file

@ -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"))