# HG changeset patch # User Brian Neal # Date 1378012542 18000 # Node ID 988782c6ce6c216de5bf5863363dc7759467d6dc # Parent a8dc08cc5db40db931820a0d1f4f2aa7d0d7f4c6 For #48, rework blocking code to use fail2ban. diff -r a8dc08cc5db4 -r 988782c6ce6c accounts/__init__.py --- 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) diff -r a8dc08cc5db4 -r 988782c6ce6c accounts/forms.py --- 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)) diff -r a8dc08cc5db4 -r 988782c6ce6c accounts/management/commands/rate_limit_clear.py --- 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) diff -r a8dc08cc5db4 -r 988782c6ce6c accounts/tests/view_tests.py --- 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): """ diff -r a8dc08cc5db4 -r 988782c6ce6c accounts/views.py --- 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. diff -r a8dc08cc5db4 -r 988782c6ce6c antispam/__init__.py --- 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) diff -r a8dc08cc5db4 -r 988782c6ce6c antispam/decorators.py --- 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 diff -r a8dc08cc5db4 -r 988782c6ce6c antispam/rate_limit.py --- 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 diff -r a8dc08cc5db4 -r 988782c6ce6c antispam/receivers.py --- /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') diff -r a8dc08cc5db4 -r 988782c6ce6c antispam/tests/__init__.py --- 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 * diff -r a8dc08cc5db4 -r 988782c6ce6c antispam/tests/rate_limit_tests.py --- 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') diff -r a8dc08cc5db4 -r 988782c6ce6c sg101/settings/local.py --- 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', diff -r a8dc08cc5db4 -r 988782c6ce6c sg101/settings/production.py --- 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', diff -r a8dc08cc5db4 -r 988782c6ce6c sg101/templates/antispam/blocked.html --- 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 %} -

Blocked

-

-Oops, we are detecting some strange behavior and are blocking this action. If you -feel this is an error, please feel to contact us -and let us know what you were doing on the site. Thank you. -

-{% endblock %}