Limit the rate of logins.

This commit is contained in:
Diana Huang
2013-07-31 16:39:10 -04:00
parent a628b62d1e
commit c867be7961
20 changed files with 171 additions and 106 deletions

View File

@@ -3,7 +3,7 @@ django admin pages for courseware model
'''
from external_auth.models import *
from django.contrib import admin
from ratelimitbackend import admin
class ExternalAuthMapAdmin(admin.ModelAdmin):

View File

@@ -9,12 +9,15 @@ from urlparse import parse_qs
from django.conf import settings
from django.test import TestCase, LiveServerTestCase
from django.core.cache import cache
from django.test.utils import override_settings
# from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.test.client import RequestFactory
from unittest import skipUnless
from student.tests.factories import UserFactory
from external_auth.views import provider_login
class MyFetcher(HTTPFetcher):
"""A fetcher that uses server-internal calls for performing HTTP
@@ -199,6 +202,49 @@ class OpenIdProviderTest(TestCase):
""" Test for 403 error code when the url"""
self.attempt_login(403, return_to="http://apps.cs50.edx.or")
def _send_bad_redirection_login(self):
"""
Attempt to log in to the provider with setup parameters
Intentionally fail the login to force a redirect
"""
user = UserFactory()
factory = RequestFactory()
post_params = {'email': user.email, 'password': 'password'}
fake_url = 'fake url'
request = factory.post(reverse('openid-provider-login'), post_params)
openid_setup = {
'request': factory.request(),
'url': fake_url
}
request.session = {
'openid_setup': openid_setup
}
response = provider_login(request)
return response
@skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or
settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True)
def test_login_openid_handle_redirection(self):
""" Test to see that we can handle login redirection properly"""
response = self._send_bad_redirection_login()
self.assertEquals(response.status_code, 302)
@skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or
settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True)
def test_login_openid_handle_redirection_ratelimited(self):
# try logging in 30 times, the default limit in the number of failed
# log in attempts before the rate gets limited
for _ in xrange(30):
self._send_bad_redirection_login()
response = self._send_bad_redirection_login()
# verify that we are not returning the default 403
self.assertEquals(response.status_code, 302)
# clear the ratelimit cache so that we don't fail other logins
cache.clear()
class OpenIdProviderLiveServerTest(LiveServerTestCase):
"""

View File

@@ -39,6 +39,7 @@ from openid.consumer.consumer import SUCCESS
from openid.server.server import Server, ProtocolError, UntrustedReturnURL
from openid.server.trustroot import TrustRoot
from openid.extensions import ax, sreg
from ratelimitbackend.exceptions import RateLimitException
import student.views as student_views
# Required for Pearson
@@ -191,7 +192,7 @@ def _external_login_or_signup(request,
user.backend = auth_backend
AUDIT_LOG.info('Linked user "%s" logged in via Shibboleth', user.email)
else:
user = authenticate(username=uname, password=eamap.internal_password)
user = authenticate(username=uname, password=eamap.internal_password, request=request)
if user is None:
# we want to log the failure, but don't want to log the password attempted:
AUDIT_LOG.warning('External Auth Login failed for "%s"', uname)
@@ -718,7 +719,12 @@ def provider_login(request):
# Failure is again redirected to the login dialog.
username = user.username
password = request.POST.get('password', None)
user = authenticate(username=username, password=password)
try:
user = authenticate(username=username, password=password, request=request)
except RateLimitException:
AUDIT_LOG.warning('OpenID - Too many failed login attempts.')
return HttpResponseRedirect(openid_request_url)
if user is None:
request.session['openid_error'] = True
msg = "OpenID login failed - password for %s is invalid"

View File

@@ -4,7 +4,7 @@ django admin pages for courseware model
from student.models import UserProfile, UserTestGroup, CourseEnrollmentAllowed
from student.models import CourseEnrollment, Registration, PendingNameChange
from django.contrib import admin
from ratelimitbackend import admin
admin.site.register(UserProfile)

View File

@@ -6,6 +6,7 @@ from mock import patch
from django.test import TestCase
from django.test.client import Client
from django.core.cache import cache
from django.core.urlresolvers import reverse, NoReverseMatch
from student.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory
@@ -29,6 +30,7 @@ class LoginTest(TestCase):
# Create the test client
self.client = Client()
cache.clear()
# Store the login url
try:
@@ -95,6 +97,27 @@ class LoginTest(TestCase):
self.assertEqual(response.status_code, 302)
self._assert_audit_log(mock_audit_log, 'info', [u'Logout', u'test'])
def test_login_ratelimited_success(self):
# Try (and fail) logging in with fewer attempts than the limit of 30
# and verify that you can still successfully log in afterwards.
for i in xrange(20):
password = u'test_password{0}'.format(i)
response, _audit_log = self._login_response('test@edx.org', password)
self._assert_response(response, success=False)
# now try logging in with a valid password
response, _audit_log = self._login_response('test@edx.org', 'test_password')
self._assert_response(response, success=True)
def test_login_ratelimited(self):
# try logging in 30 times, the default limit in the number of failed
# login attempts in one 5 minute period before the rate gets limited
for i in xrange(30):
password = u'test_password{0}'.format(i)
self._login_response('test@edx.org', password)
# check to see if this response indicates that this was ratelimited
response, _audit_log = self._login_response('test@edx.org', 'wrong_password')
self._assert_response(response, success=False, value='Too many failed login attempts')
def _login_response(self, email, password, patched_audit_log='student.views.AUDIT_LOG'):
''' Post the login info '''
post_params = {'email': email, 'password': password}

View File

@@ -28,6 +28,8 @@ from django.utils.http import cookie_date
from django.utils.http import base36_to_int
from django.utils.translation import ugettext as _
from ratelimitbackend.exceptions import RateLimitException
from mitxmako.shortcuts import render_to_response, render_to_string
from bs4 import BeautifulSoup
@@ -421,13 +423,23 @@ def login_user(request, error=""):
user = User.objects.get(email=email)
except User.DoesNotExist:
AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email))
return HttpResponse(json.dumps({'success': False,
'value': _('Email or password is incorrect.')})) # TODO: User error message
user = None
username = user.username
user = authenticate(username=username, password=password)
# if the user doesn't exist, we want to set the username to an invalid
# username so that authentication is guaranteed to fail and we can take
# advantage of the ratelimited backend
username = user.username if user else ""
try:
user = authenticate(username=username, password=password, request=request)
# this occurs when there are too many attempts from the same IP address
except RateLimitException:
return HttpResponse(json.dumps({'success': False,
'value': _('Too many failed login attempts. Try again later.')}))
if user is None:
AUDIT_LOG.warning(u"Login failed - password for {0} is invalid".format(email))
# if we didn't find this username earlier, the account for this email
# doesn't exist, and doesn't have a corresponding password
if username != "":
AUDIT_LOG.warning(u"Login failed - password for {0} is invalid".format(email))
return HttpResponse(json.dumps({'success': False,
'value': _('Email or password is incorrect.')}))
@@ -942,7 +954,7 @@ def auto_auth(request):
# if they already are a user, log in
try:
user = User.objects.get(username=username)
user = authenticate(username=username, password=password)
user = authenticate(username=username, password=password, request=request)
login(request, user)
# else create and activate account info

View File

@@ -3,6 +3,6 @@ django admin pages for courseware model
'''
from track.models import TrackingLog
from django.contrib import admin
from ratelimitbackend import admin
admin.site.register(TrackingLog)