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; } }