Brute-force attack prevention is a topic that gets mentioned every now and then in the Django community, on mailing-lists and at conferences. I've been thinking about this a little bit and I believe I found a very nice solution.
Note that I'm only tackling the issue of brute-forcing user credentials, not rate-limiting in general.
There are a bunch of ways to monitor login attempts:
- With a decorator applied to every login view,
- With a middleware that inspects incoming requests (note: this is a bad idea),
- With Nginx (or whatever your web server is), using HttpLimitReqModule
Neither of these solutions are optimal:
- the admin accepts login attempts at arbitrary URLs, which makes it difficult to monitor,
- there are different ways to authenticate users, maybe you have an API that does Basic authentication,
- the nginx syntax is hard to get and maybe some day you'll need to move to something else.
The solution is to put the rate-limit logic and the credentials verification logic at the same place: on the authentication backend.
To do this, we need to make the authentication backend request-aware, in order to block people based on some criteria such as their IP or User-Agent. This requires:
- A custom login view, to pass the request to the authentication form,
- A custom authentication form, to pass the request to the authentication backend,
- A custom authentication backend, to make use of the request it gets,
- A custom admin site, to use the custom login view in the admin too.
First, we'll need to rewrite Django's login view and replace:
if request.method == "POST": form = authentication_form(data=request.POST)
if request.method == "POST": form = authentication_form(data=request.POST, request=request)
The default authentication form already accepts a request, it'll store it on self. But we still need to subclass it since it's the form that calls the authentication backend:
from django.contrib.auth.forms import AuthenticationForm as AuthForm class AuthenticationForm(AuthForm): def clean(self): username = self.cleaned_data.get('username') password = self.cleaned_data.get('password') if username and password: self.user_cache = authenticate(username=username, password=password, request=self.request) if self.user_cache is None: raise forms.ValidationError( _('Please enter a correct username and password. ' 'Note that both fields are case-sensitive.'), ) elif not self.user_cache.is_active: raise forms.ValidationError(_('This account is inactive.')) self.check_for_test_cookie() return self.cleaned_data
Here we're calling authenticate(username, password, request), the only thing left is to implement the logic on the backend itself. This represents a little bit more code so I'll just link to an implementation, which I've packaged and released under the name of django-ratelimit-backend.
The rate-limiting strategy is borrowed from Simon Willison's ratelimit-cache idea: failed attempts are cached for 5 minutes and if more than X attempts (30 by default) are made, further attempts are blocked until there are less than X attempts in the past 5 minutes.
In order to fully protect your django site, configure your cache backend and go through the checklist (make sure every item is fulfilled).
There is a pretty significant caveat: you can't use Django's default admin site because it's not protected. You need to register your models on ratlimitbackend's admin site instead, and hook that site into your URLconf. This means all apps that automatically register their models (such as django.contrib.auth, django.contrib.sites, django-registration…) won't show up in the admin until you re-register their models on the rate-limited admin site.
This is a bit annoying but I'm happily using this in production and it works wonderfully. The documentation is extensive and it's highly customizable:
- failed attempts can be logged to let you take appropriate measures when it gets too annoying,
- you can choose the number of failed attempts you want to allow,
- you can customize the action to take when a user hits the limit.
Feel free to try it and let me know what you think.
Now how about you? What techniques do you use to prevent such attacks?