changeset 690:988782c6ce6c

For #48, rework blocking code to use fail2ban.
author Brian Neal <bgneal@gmail.com>
date Sun, 01 Sep 2013 00:15:42 -0500
parents a8dc08cc5db4
children 81e0be69b3a5
files accounts/__init__.py accounts/forms.py accounts/management/__init__.py accounts/management/commands/__init__.py accounts/management/commands/rate_limit_clear.py accounts/tests/view_tests.py accounts/views.py antispam/__init__.py antispam/decorators.py antispam/rate_limit.py antispam/receivers.py antispam/tests/__init__.py antispam/tests/rate_limit_tests.py sg101/settings/local.py sg101/settings/production.py sg101/templates/antispam/blocked.html
diffstat 14 files changed, 97 insertions(+), 342 deletions(-) [+]
line wrap: on
line diff
--- a/accounts/__init__.py	Sat Aug 31 14:50:03 2013 -0500
+++ b/accounts/__init__.py	Sun Sep 01 00:15:42 2013 -0500
@@ -4,6 +4,9 @@
 from django.contrib.auth.models import User
 
 
+logger = logging.getLogger('auth')
+
+
 def create_new_user(pending_user, ip=None, admin_activation=False):
     """
     This function contains the code to create a new user from a
@@ -34,4 +37,4 @@
         msg = 'Accounts registration confirmed by USER for %s from %s' % (
                 new_user.username, ip)
 
-    logging.info(msg)
+    logger.info(msg)
--- a/accounts/forms.py	Sat Aug 31 14:50:03 2013 -0500
+++ b/accounts/forms.py	Sun Sep 01 00:15:42 2013 -0500
@@ -13,7 +13,9 @@
 from accounts.models import PendingUser
 from accounts.models import IllegalUsername
 from accounts.models import IllegalEmail
-from antispam.rate_limit import block_ip
+
+
+logger = logging.getLogger('auth')
 
 
 class RegisterForm(forms.Form):
@@ -113,8 +115,8 @@
         """
         answer = self.cleaned_data.get('question2')
         if answer:
-            block_ip(self.ip)
-            self._validation_error('Wrong answer #2: %s' % answer)
+            logger.critical('Accounts/registration: Honeypot filled [%s]', self.ip)
+            self._validation_error('Wrong answer #2', answer)
         return answer
 
     def save(self):
@@ -143,13 +145,13 @@
         subject = 'Registration Confirmation for ' + site.name
         send_mail(subject, msg, admin_email, [self.cleaned_data['email']],
                 defer=False)
-        logging.info('Accounts/registration conf. email sent to %s for user %s; IP = %s',
+        logger.info('Accounts/registration conf. email sent to %s for user %s; IP = %s',
                 self.cleaned_data['email'], pending_user.username, self.ip)
 
         return pending_user
 
     def _validation_error(self, msg, param=None):
-        logging.error('Accounts/registration [%s]: %s (%s)', self.ip, msg, param)
+        logger.error('Accounts/registration [%s]: %s (%s)', self.ip, msg, param)
         raise forms.ValidationError(msg)
 
 
@@ -178,5 +180,5 @@
             })
         send_mail(subject, msg, admin_email, [email], defer=False)
 
-        logging.info('Forgotten username email sent to {} <{}>'.format(
+        logger.info('Forgotten username email sent to {} <{}>'.format(
             user.username, email))
--- a/accounts/management/commands/rate_limit_clear.py	Sat Aug 31 14:50:03 2013 -0500
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,54 +0,0 @@
-"""
-The rate_limit_clear command is used to clear IP addresses out from our rate
-limit protection database.
-
-"""
-from optparse import make_option
-import re
-
-from django.core.management.base import BaseCommand
-import redis
-
-from core.services import get_redis_connection
-
-
-IP_RE = re.compile(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$')
-
-
-class Command(BaseCommand):
-    help = """Remove IP addresses from the rate limit protection datastore."""
-    option_list = list(BaseCommand.option_list) + [
-        make_option("--purge", action="store_true",
-            help="Purge all IP addresses"),
-    ]
-
-    def handle(self, *args, **kwargs):
-        try:
-            con = get_redis_connection()
-
-            # get all rate-limit keys
-            keys = con.keys('rate-limit-*')
-
-            # if purging, delete them all...
-            if kwargs['purge']:
-                if keys:
-                    con.delete(*keys)
-                return
-
-            # otherwise delete the ones the user asked for
-            ips = []
-            for ip in args:
-                if IP_RE.match(ip):
-                    key = 'rate-limit-%s' % ip
-                    if key in keys:
-                        ips.append(key)
-                    else:
-                        self.stdout.write('%s not found\n' % ip)
-                else:
-                    self.stderr.write('invalid IP address %s\n' % ip)
-
-            if ips:
-                con.delete(*ips)
-
-        except redis.RedisError, e:
-            self.stderr.write('%s\n' % e)
--- a/accounts/tests/view_tests.py	Sat Aug 31 14:50:03 2013 -0500
+++ b/accounts/tests/view_tests.py	Sun Sep 01 00:15:42 2013 -0500
@@ -10,7 +10,6 @@
 from django.contrib.auth.models import User
 from django.contrib.auth.hashers import check_password
 
-from antispam.rate_limit import unblock_ip
 from accounts.models import PendingUser
 from accounts.models import IllegalUsername
 from accounts.models import IllegalEmail
@@ -35,9 +34,6 @@
         IllegalUsername.objects.create(username='illegalusername')
         IllegalEmail.objects.create(email='illegal@example.com')
 
-    def tearDown(self):
-        unblock_ip('127.0.0.1')
-
     def test_get_view(self):
         """
         Test a simple get of the registration view
@@ -223,7 +219,7 @@
             'question2': 'non blank',
             })
 
-        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.status_code, 200)
 
     def test_success(self):
         """
--- a/accounts/views.py	Sat Aug 31 14:50:03 2013 -0500
+++ b/accounts/views.py	Sun Sep 01 00:15:42 2013 -0500
@@ -2,7 +2,6 @@
 Views for the accounts application.
 
 """
-import datetime
 import json
 import logging
 
@@ -18,12 +17,14 @@
 from accounts.models import PendingUser
 from accounts.forms import RegisterForm, ForgotUsernameForm
 from accounts import create_new_user
-from antispam.decorators import rate_limit
+from antispam.decorators import log_auth_failures
 
 
+logger = logging.getLogger('auth')
+
 #######################################################################
 
-@rate_limit(count=10, interval=datetime.timedelta(minutes=1))
+@log_auth_failures('Register')
 def register(request):
     if request.user.is_authenticated():
         return HttpResponseRedirect(settings.LOGIN_REDIRECT_URL)
@@ -60,13 +61,13 @@
     try:
         pending_user = PendingUser.objects.get(username = username)
     except PendingUser.DoesNotExist:
-        logging.error('Accounts register_confirm [%s]: user does not exist: %s', ip, username)
+        logger.error('Accounts register_confirm [%s]: user does not exist: %s', ip, username)
         return render(request,
                   'accounts/register_failure.html',
                   {'username': username})
 
     if pending_user.key != key:
-        logging.error('Accounts register_confirm [%s]: key error: %s', ip, username)
+        logger.error('Accounts register_confirm [%s]: key error: %s', ip, username)
         return render(request,
                 'accounts/register_failure.html',
                 {'username': username})
@@ -79,8 +80,7 @@
 
 #######################################################################
 
-@rate_limit(count=10, interval=datetime.timedelta(minutes=1),
-        lockout=datetime.timedelta(minutes=2))
+@log_auth_failures
 def login_ajax(request):
     """
     This view function handles a login via AJAX.
--- a/antispam/__init__.py	Sat Aug 31 14:50:03 2013 -0500
+++ b/antispam/__init__.py	Sun Sep 01 00:15:42 2013 -0500
@@ -1,8 +1,7 @@
-import datetime
-
 from django.contrib.auth import views as auth_views
 
-from antispam.decorators import rate_limit
+from antispam.decorators import log_auth_failures
+import antispam.receivers
 
 SPAM_PHRASE_KEY = "antispam.spam_phrases"
 BUSTED_MESSAGE = ("Your post has tripped our spam filter. Your account has "
@@ -10,4 +9,4 @@
         "then we apologize; your account will be restored shortly.")
 
 # Install rate limiting on auth login
-auth_views.login = rate_limit(lockout=datetime.timedelta(minutes=2))(auth_views.login)
+auth_views.login = log_auth_failures('Login')(auth_views.login)
--- a/antispam/decorators.py	Sat Aug 31 14:50:03 2013 -0500
+++ b/antispam/decorators.py	Sun Sep 01 00:15:42 2013 -0500
@@ -2,33 +2,19 @@
 This module contains decorators for the antispam application.
 
 """
-from datetime import timedelta
 import json
 from functools import wraps
+import logging
 
-from django.shortcuts import render
 
-from antispam.rate_limit import RateLimiter, RateLimiterUnavailable
-
-
-def rate_limit(count=10, interval=timedelta(minutes=1),
-        lockout=timedelta(hours=8)):
+def log_auth_failures(auth_type):
 
     def decorator(fn):
+        logger = logging.getLogger('auth')
 
         @wraps(fn)
         def wrapped(request, *args, **kwargs):
 
-            ip = request.META.get('REMOTE_ADDR')
-            try:
-                rate_limiter = RateLimiter(ip, count, interval, lockout)
-                if rate_limiter.is_blocked():
-                    return render(request, 'antispam/blocked.html', status=403)
-
-            except RateLimiterUnavailable:
-                # just call the function and return the result
-                return fn(request, *args, **kwargs)
-
             response = fn(request, *args, **kwargs)
 
             if request.method == 'POST':
@@ -45,13 +31,12 @@
                     success = json_resp['success']
 
                 if not success:
-                    try:
-                        blocked = rate_limiter.incr()
-                    except RateLimiterUnavailable:
-                        blocked = False
-
-                    if blocked:
-                        return render(request, 'antispam/blocked.html', status=403)
+                    username = request.POST.get('username')
+                    username = username if username else '(None)'
+                    logger.error("%s failure from [%s] for %s",
+                            auth_type,
+                            request.META.get('REMOTE_ADDR', '?'),
+                            username)
 
             return response
 
--- a/antispam/rate_limit.py	Sat Aug 31 14:50:03 2013 -0500
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,152 +0,0 @@
-"""
-This module contains the rate limiting functionality.
-
-"""
-import datetime
-import logging
-
-import redis
-
-from core.services import get_redis_connection
-
-
-logger = logging.getLogger(__name__)
-
-
-# This exception is thrown upon any Redis error. This insulates client code from
-# knowing that we are using Redis and will allow us to use something else in the
-# future.
-class RateLimiterUnavailable(Exception):
-    pass
-
-
-def _make_key(ip):
-    """
-    Creates and returns a key string from a given IP address.
-
-    """
-    return 'rate-limit-' + ip
-
-
-def _get_connection():
-    """
-    Create and return a Redis connection. Returns None on failure.
-    """
-    try:
-        conn = get_redis_connection()
-    except redis.RedisError, e:
-        logger.error("rate limit: %s" % e)
-        raise RateLimiterUnavailable
-
-    return conn
-
-
-def _to_seconds(interval):
-    """
-    Converts the timedelta interval object into a count of seconds.
-
-    """
-    return interval.days * 24 * 3600 + interval.seconds
-
-
-def block_ip(ip, count=1000000, interval=datetime.timedelta(weeks=2)):
-    """
-    This function jams the rate limit record for the given IP so that the IP is
-    blocked for the given interval. If the record doesn't exist, it is created.
-    This is useful for manually blocking an IP after detecting suspicious
-    behavior.
-    This function may throw RateLimiterUnavailable.
-
-    """
-    key = _make_key(ip)
-    conn = _get_connection()
-
-    try:
-        conn.setex(key, time=_to_seconds(interval), value=count)
-    except redis.RedisError, e:
-        logger.error("rate limit (block_ip): %s" % e)
-        raise RateLimiterUnavailable
-
-    logger.info("Rate limiter blocked IP %s; %d / %s", ip, count, interval)
-
-
-def unblock_ip(ip):
-    """
-    This function removes the block for the given IP address.
-
-    """
-    key = _make_key(ip)
-    conn = _get_connection()
-    try:
-        conn.delete(key)
-    except redis.RedisError, e:
-        logger.error("rate limit (unblock_ip): %s" % e)
-        raise RateLimiterUnavailable
-
-    logger.info("Rate limiter unblocked IP %s", ip)
-
-
-class RateLimiter(object):
-    """
-    This class encapsulates the rate limiting logic for a given IP address.
-
-    """
-    def __init__(self, ip, set_point, interval, lockout):
-        self.ip = ip
-        self.set_point = set_point
-        self.interval = interval
-        self.lockout = lockout
-        self.key = _make_key(ip)
-        self.conn = _get_connection()
-
-    def is_blocked(self):
-        """
-        Return True if the IP is blocked, and false otherwise.
-
-        """
-        try:
-            val = self.conn.get(self.key)
-        except redis.RedisError, e:
-            logger.error("RateLimiter (is_blocked): %s" % e)
-            raise RateLimiterUnavailable
-
-        try:
-            val = int(val) if val else 0
-        except ValueError:
-            return False
-
-        blocked = val >= self.set_point
-        if blocked:
-            logger.info("Rate limiter blocking %s", self.ip)
-
-        return blocked
-
-    def incr(self):
-        """
-        One is added to a counter associated with the IP address. If the
-        counter exceeds set_point per interval, True is returned, and False
-        otherwise. If the set_point is exceeded for the first time, the counter
-        associated with the IP is set to expire according to the lockout
-        parameter.
-
-        """
-        try:
-            val = self.conn.incr(self.key)
-
-            # Set expire time, if necessary.
-            # If this is the first time, set it according to interval.
-            # If the set_point has just been exceeded, set it according to lockout.
-            if val == 1:
-                self.conn.expire(self.key, _to_seconds(self.interval))
-            elif val == self.set_point:
-                self.conn.expire(self.key, _to_seconds(self.lockout))
-
-            tripped = val >= self.set_point
-
-            if tripped:
-                logger.info("Rate limiter tripped for %s; counter = %d", self.ip, val)
-            return tripped
-
-        except redis.RedisError, e:
-            logger.error("RateLimiter (incr): %s" % e)
-            raise RateLimiterUnavailable
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/antispam/receivers.py	Sun Sep 01 00:15:42 2013 -0500
@@ -0,0 +1,36 @@
+""" receivers.py - Signal receivers for login related events.
+
+We log these events so that fail2ban can perform rate limiting.
+
+"""
+import logging
+
+from django.contrib.auth.signals import (user_logged_in, user_logged_out,
+        user_login_failed)
+
+
+# Get the auth logger that is monitored by fail2ban:
+logger = logging.getLogger('auth')
+
+
+def login_callback(sender, request, user, **kwargs):
+    """Signal callback function for a user logging in."""
+    logger.info('User login signal: %s', user.username)
+
+
+def logout_callback(sender, request, user, **kwargs):
+    """Signal callback function for a user logging in."""
+
+    if user:
+        logger.info('User logout signal: %s', user.username)
+
+def login_failed_callback(sender, credentials, **kwargs):
+    """Signal callback for a login failure event."""
+    logger.error('User login failed signal from %s: %s', sender,
+                 credentials.get('username'))
+
+
+user_logged_in.connect(login_callback, dispatch_uid='antispam.receivers')
+user_logged_out.connect(logout_callback, dispatch_uid='antispam.receivers')
+user_login_failed.connect(login_failed_callback,
+                          dispatch_uid='antispam.receivers')
--- a/antispam/tests/__init__.py	Sat Aug 31 14:50:03 2013 -0500
+++ b/antispam/tests/__init__.py	Sun Sep 01 00:15:42 2013 -0500
@@ -1,2 +1,1 @@
-from rate_limit_tests import *
 from utils_tests import *
--- a/antispam/tests/rate_limit_tests.py	Sat Aug 31 14:50:03 2013 -0500
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,77 +0,0 @@
-"""
-Tests for the rate limiting function in the antispam application.
-
-"""
-from django.test import TestCase
-from django.core.urlresolvers import reverse
-
-from antispam.rate_limit import _make_key
-from core.services import get_redis_connection
-
-
-class RateLimitTestCase(TestCase):
-    KEY = _make_key('127.0.0.1')
-
-    def setUp(self):
-        self.conn = get_redis_connection()
-        self.conn.delete(self.KEY)
-
-    def tearDown(self):
-        self.conn.delete(self.KEY)
-
-    def testRegistrationLockout(self):
-
-        for i in range(1, 11):
-            response = self.client.post(
-                    reverse('accounts-register'),
-                    {},
-                    follow=True)
-
-            if i < 10:
-                self.assertEqual(response.status_code, 200)
-                self.assertTemplateUsed(response, 'accounts/register.html')
-            elif i >= 10:
-                self.assertEqual(response.status_code, 403)
-                self.assertTemplateUsed(response, 'antispam/blocked.html')
-
-    def testLoginLockout(self):
-
-        for i in range(1, 11):
-            response = self.client.post(
-                    reverse('accounts-login'),
-                    {},
-                    follow=True)
-
-            if i < 10:
-                self.assertEqual(response.status_code, 200)
-                self.assertTemplateUsed(response, 'accounts/login.html')
-            elif i >= 10:
-                self.assertEqual(response.status_code, 403)
-                self.assertTemplateUsed(response, 'antispam/blocked.html')
-
-    def testHoneypotLockout(self):
-
-        response = self.client.post(
-                reverse('accounts-register'), {
-                    'username': u'test_user',
-                    'email': u'test_user@example.com',
-                    'password1': u'password',
-                    'password2': u'password',
-                    'agree_age': u'on',
-                    'agree_tos': u'on',
-                    'agree_privacy': u'on',
-                    'question1': u'101',
-                    'question2': u'DsjkdE$',
-                },
-                follow=True)
-
-        val = self.conn.get(self.KEY)
-        self.assertEqual(val, '1000001')
-
-        response = self.client.post(
-                reverse('accounts-login'),
-                {},
-                follow=True)
-
-        self.assertEqual(response.status_code, 403)
-        self.assertTemplateUsed(response, 'antispam/blocked.html')
--- a/sg101/settings/local.py	Sat Aug 31 14:50:03 2013 -0500
+++ b/sg101/settings/local.py	Sun Sep 01 00:15:42 2013 -0500
@@ -65,6 +65,15 @@
             'maxBytes': 100 * 1024,
             'backupCount': 10,
         },
+        'auth': {
+            'class': 'logging.handlers.RotatingFileHandler',
+            'level': 'DEBUG',
+            'formatter': 'simple',
+            'filename': os.path.join(PROJECT_PATH, 'logs', 'auth.log'),
+            'mode': 'a',
+            'maxBytes': 2 * 1024 * 1024,
+            'backupCount': 5,
+        },
         'mail_admins': {
             'class': 'django.utils.log.AdminEmailHandler',
             'level': 'ERROR',
@@ -73,11 +82,16 @@
         },
     },
     'loggers': {
-        'django':{
+        'django': {
             'level': 'WARNING',
             'propagate': False,
             'handlers': ['file'],
         },
+        'auth': {
+            'level': 'DEBUG',
+            'propagate': False,
+            'handlers': ['auth'],
+        },
     },
     'root': {
         'level': 'DEBUG',
--- a/sg101/settings/production.py	Sat Aug 31 14:50:03 2013 -0500
+++ b/sg101/settings/production.py	Sun Sep 01 00:15:42 2013 -0500
@@ -77,6 +77,15 @@
             'maxBytes': 100 * 1024,
             'backupCount': 10,
         },
+        'auth': {
+            'class': 'logging.handlers.RotatingFileHandler',
+            'level': 'INFO',
+            'formatter': 'simple',
+            'filename': os.path.join(PROJECT_PATH, 'logs', 'auth.log'),
+            'mode': 'a',
+            'maxBytes': 2 * 1024 * 1024,
+            'backupCount': 5,
+        },
         'mail_admins': {
             'class': 'django.utils.log.AdminEmailHandler',
             'level': 'ERROR',
@@ -95,6 +104,11 @@
             'propagate': True,
             'handlers': ['mail_admins'],
         },
+        'auth': {
+            'level': 'INFO',
+            'propagate': False,
+            'handlers': ['auth'],
+        },
     },
     'root': {
         'level': 'INFO',
--- a/sg101/templates/antispam/blocked.html	Sat Aug 31 14:50:03 2013 -0500
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,10 +0,0 @@
-{% extends 'base.html' %}
-{% block title %}Blocked{% endblock %}
-{% block content %}
-<h2>Blocked</h2>
-<p class="error">
-Oops, we are detecting some strange behavior and are blocking this action. If you
-feel this is an error, please feel to <a href="{% url 'contact-form' %}">contact us</a>
-and let us know what you were doing on the site. Thank you.
-</p>
-{% endblock %}