Django admin has a form where you can login with an account and see the various admin screens you have access to. As with any login screen it's good to detect multiple failures and restrict the access of any offending user's IP address. Pointing fail2ban at the nginx logs should be enough but not in this case. Django admin's login screen will raise a form error and return an HTTP 200 when invalid credentials are given.
... "POST /admin/login/?next=/admin/ HTTP/1.1" 200 939 ...
I was surprised by this when I discovered it and I raised a ticket with the Django project. Erik Romijn, one of the core Django developers reported back quickly stating:
I can see your point, but the behaviour is correct and intentional. The 200 response means the same form is re-rendered, now including an error message. The user can edit their input and try again. A 403 response would cause the browser to display a much less friendly error, without offering a reasonable way for a user to correct their error.
Logging authentication failures
So simply relying on the out-of-the-box HTTP status codes isn't going to be enough so I decided to look at the django.contrib.auth.signals.user_login_failed signal and see if I could pick up the user's IP address when they've submitted invalid credentials and record it to a log file.
To start, here is a minimal nginx configuration that will setup a reverse proxy and report the user's IP address to Django:
upstream app_servers {
server 127.0.0.1:8000;
}
server {
listen 80;
location / {
proxy_pass http://app_servers;
proxy_read_timeout 90;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
For the sake of simplicity I've placed the above in /etc/nginx/sites-available/default.
I then created a virtual environment for a new project, installed Django and then generated the boilerplate code for the project:
$ cd ~/.virtualenvs
$ virtualenv service
$ source service/bin/activate
$ pip install 'Django>=1.7.4,<1.8'
$ cd ~/
$ django-admin startproject service
I added the admin URLs to service/urls.py:
from django.conf.urls import patterns, include, url
from django.contrib import admin
urlpatterns = patterns('',
url(r'^admin/', include(admin.site.urls)),
)
Inside the project I created a new application called login_failure:
$ cd service
$ mkdir login_failure
$ touch login_failure/__init__.py
$ touch login_failure/middleware.py
$ touch login_failure/signals.py
user_login_failed doesn't pass the request object
When user_login_failed is called the request object is not sent so I looked for some middleware that would allow me to get the request object. django-contrib-requestprovider looked promising but the PyPI install didn't work and I couldn't find a way to install via the git URL in a way that would be requirements.txt-friendly.
$ pip install django-contrib-requestprovider
Downloading/unpacking django-contrib-requestprovider
Could not find any downloads that satisfy the requirement django-contrib-requestprovider
Some externally hosted files were ignored (use --allow-external django-contrib-requestprovider to allow).
...
$ pip install --allow-external django-contrib-requestprovider
You must give at least one requirement to install (see "pip help install")
$ pip install -e git+https://github.com/malfaux/snakecheese.git#egg=requestprovider
...
IOError: [Errno 2] No such file or directory: '/home/mark/.virtualenvs/rest_service/src/requestprovider/setup.py'
I decided to re-fashion some of the code in the library in my application instead. Annoyingly, it was only after I finished this blog post that I came across Nephila's fork of the project with a functioning setup.py.
login_failure/signals.py:
from django.dispatch import Signal
class UnauthorizedSignalReceiver(Exception):
pass
class SingleHandlerSignal(Signal):
allowed_receiver = 'login_failure.middleware.RequestProvider'
def __init__(self, providing_args=None):
return Signal.__init__(self, providing_args)
def connect(self, receiver, sender=None, weak=True, dispatch_uid=None):
receiver_name = '.'.join([receiver.__class__.__module__,
receiver.__class__.__name__])
if receiver_name != self.allowed_receiver:
raise UnauthorizedSignalReceiver()
Signal.connect(self, receiver, sender, weak, dispatch_uid)
request_accessor = SingleHandlerSignal()
def get_request():
return request_accessor.send(None)[0][1]
login_failure/middleware.py:
from login_failure.signals import request_accessor
class RequestProviderError(Exception):
pass
class RequestProvider(object):
def __init__(self):
self._request = None
request_accessor.connect(self)
def process_request(self, request):
self._request = request
return None
def __call__(self, **kwargs):
return self._request
login_failure/__init__.py:
import logging
from django.contrib.auth.signals import user_login_failed
from login_failure.signals import get_request
logger = logging.getLogger(__name__)
def log_login_failure(sender, credentials, **kwargs):
http_request = get_request()
msg = "Login failure {}".format(http_request.META['REMOTE_ADDR'])
logger.error(msg)
user_login_failed.connect(log_login_failure)
I then added login_failure.middleware.RequestProvider to settings.MIDDLEWARE_CLASSES and added the following logging setup at the bottom of service/settings.py:
import logging
log = logging.getLogger()
log.setLevel(logging.INFO)
fh = logging.FileHandler('/tmp/django.log', encoding='utf-8')
fh.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
fh.setFormatter(formatter)
log.addHandler(fh)
After starting the reference WSGI server I could see login failures recorded to /tmp/django.log in the following fashion:
2015-03-10 21:31:23,899 ERROR Login failure 555.5.5.6
2015-03-10 21:31:23,899 ERROR Login failure 555.5.5.6
2015-03-10 21:31:23,899 ERROR Login failure 555.5.5.6
Pointing fail2ban at Django's log
The installation and configuration process for fail2ban is very straight forward:
$ sudo apt install fail2ban
I created a filter in /etc/fail2ban/filter.d/django-auth.conf with the following contents:
[Definition]
failregex = Login failure <HOST>
ignoreregex =
And then tied the filter to Django's log file in /etc/fail2ban/jail.local:
[django-auth]
enabled = true
filter = django-auth
port = 80,443
logpath = /tmp/django.log
I then did a check that the failregex would match against Django's log file properly:
$ fail2ban-regex /tmp/django.log /etc/fail2ban/filter.d/django-auth.conf
Running tests
=============
Use failregex file : /etc/fail2ban/filter.d/django-auth.conf
Use log file : /tmp/django.log
Results
=======
Failregex: 30 total
|- #) [# of hits] regular expression
| 1) [30] Login failure <HOST>
`-
Ignoreregex: 0 total
Date template hits:
|- [# of hits] date format
| [30] Year-Month-Day Hour:Minute:Second[,subsecond]
`-
Lines: 30 lines, 0 ignored, 30 matched, 0 missed
After restarting fail2ban I could see offending IP addresses being banned:
$ grep Ban /var/log/fail2ban.log
2015-03-10 21:31:31,057 fail2ban.actions[10473]: WARNING [django-auth] Ban 555.5.5.6
2015-03-10 21:33:21,664 fail2ban.actions[10769]: WARNING [django-auth] Ban 555.5.5.7
2015-03-10 21:33:34,695 fail2ban.actions[10769]: WARNING [django-auth] Ban 555.5.5.8
What about white lists?
If you're hosting with a PaaS provider like Heroku fail2ban might not be much help for you. Likewise, if an attacker knows your login and password then you could use an IP address white list as your next line of defence.
Django admin allows for overriding the authentication form called by its login screen. To do so I first created a new setting ADMIN_IP_WHITELIST in service/settings.py:
ADMIN_IP_WHITELIST = ('555.5.5.1',)
I then created white_list/__init__.py in the project with the following contents:
from django import forms
from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm
from login_failure.signals import get_request
class IPRestrictedLoginForm(AuthenticationForm):
def __init__(self, request=None, *args, **kwargs):
super(IPRestrictedLoginForm, self).__init__(*args, **kwargs)
def confirm_login_allowed(self, user):
if not user.is_active:
raise forms.ValidationError(
self.error_messages['inactive'],
code='inactive',
)
http_request = get_request()
if http_request.META['REMOTE_ADDR'] not in settings.ADMIN_IP_WHITELIST:
raise forms.ValidationError('Your IP address is not in white list')
With that in place I modified service/urls.py:
from django.conf.urls import patterns, include, url
from django.contrib import admin
from white_list import IPRestrictedLoginForm
admin.site.login_form = IPRestrictedLoginForm
urlpatterns = patterns('',
url(r'^admin/', include(admin.site.urls)),
)
The above is good for controlling access via the Django admin login form but if you're using django.contrib.auth.login directly anywhere in your code you would need to reference settings.ADMIN_IP_WHITELIST there as well.
What about Django Axes?
Django Axes looks like it could replace a lot of the above code. It can integrate with fail2ban by catching the user_locked_out signal, which includes the request object and logging the lock out to Django's log file. This would save the need for login_failure/middleware.py and login_failure/signals.py.
There is also feature request open for white list support. If this is implemented this library could be a one-stop-shop for blocking users with too many failed login attempts and restricting which users get a chance to even attempt to authenticate in the first place.
The only downside of Django Axes that I can see is coordinating lock out expiry times between fail2ban and Django Axes so that they're roughly in sync. Also, if a lock out occurred in error and you wish to remove it then there are two places you'd need to remove it from.
Fail2ban can block TCP/IP communications from an attacker from even reaching nginx, it comes with Bad IPs support and includes monitors for a large number of services so I wouldn't want to do without it.
Keeping white lists out of the code base
Another way to look at managing white lists might be to keep them controlled at the reverse-proxy level. The following is an example nginx config that would restrict calls to /admin/ to 123.456.789.012:
upstream app_servers {
server 127.0.0.1:8000;
}
server {
listen 80;
location ^~ /admin/ {
allow 123.456.789.012;
deny all;
proxy_pass http://app_servers;
proxy_read_timeout 90;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
proxy_pass http://app_servers;
proxy_read_timeout 90;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
An attacker knows my login and password and is proxying through my computer
As a third line of defence you could implement Django Two-Factor Authentication and configure it to use Twilio to call your mobile phone or SMS you a one-time code.