Django: Log and block brute force attempts

Here is a very simple mechanism for wrapping a decorator around your views to protect them against brute force attempts. For instance, if you have a secret file download available only with the right secret (/view/id/secret-hash/), you expose your view to simple brute force attempts.

Simply put, this decorator will log a 404 response object or Http404 exception, count pr. IP and return status=400 and send you an email whenever a new block is put into place.

Model

class IllegalLookup(LogModifications):
    """Log and block illegal lookups"""
    created = models.DateTimeField(
        verbose_name=_(u'created'),
        auto_now_add = True,
    )
    modified = models.DateTimeField(
        verbose_name=_(u'created'),
        auto_now = True,
    )
    ip_address = models.CharField(
        max_length=16,
        null=True,
        blank=True,
        verbose_name=_(u'IP address'),
    )
    path = models.CharField(
        max_length=255,
        null=True,
        blank=True,
        verbose_name=_(u'path'),
        help_text=_(u'First attempted path is always logged'),
    )
    count = models.PositiveIntegerField(
        default=1,
    )
 
    @classmethod
    def log_lookup(cls, ip_address, path):
        try:
            now = timezone.now()
            expired = now - timedelta(minutes=settings.BLOCK_EXPIRY)
            lookup = cls.objects.get(ip_address=ip_address,
                modified__gte=expired)
            lookup.count += 1
            lookup.save()
        except cls.DoesNotExist:
            # Delete old entries first
            cls.objects.filter(ip_address=ip_address).delete()
            lookup = cls.objects.create(ip_address=ip_address,
                path=path)
            lookup.save()
 
    @classmethod
    def is_blocked(cls, ip_address):
        try:
            now = timezone.now()
            expired = now - timedelta(minutes=settings.BLOCK_EXPIRY)
            lookup = cls.objects.get(ip_address=ip_address,
                modified__gte=expired)
            if lookup.count == settings.BLOCK_ATTEMPTS:
                mail_admins("IP blocked", "{0} is now blocked, IllegalLookup id: {1}".format(ip_address, lookup.id))
            if lookup.count > settings.BLOCK_ATTEMPTS:
                return True
        except cls.DoesNotExist:
            pass
        return False

Decorator

def log_and_block(func):
 
    def _log_and_block(request, *args, **kwargs):
        remote_ip = request.META.get('REMOTE_ADDR', None)
        if IllegalLookup.is_blocked(remote_ip):
            return HttpResponse('%s is blocked' % remote_ip,
                status=400)
 
        is_404 = False
        is_exception = False
        try:
            return_object = func(request, *args, **kwargs)
            if return_object.status_code == 404:
                is_404 = True
        except Http404:
            is_404 = True
            is_exception = True
 
        if is_404:
            if remote_ip:
                IllegalLookup.log_lookup(remote_ip,
                    request.META.get('PATH_INFO', ''))
 
            if is_exception:
                raise
 
        return return_object
 
    return _log_and_block

Usage

Now, simply wrap the decorator around your view:

@log_and_block
def my_view(request, id, secret_hash):
    object = get_object_or_404(models.MyModel, id=id, secret_hash=secret_hash)