Commit 29fea1dc by Chris Dodge Committed by Xavier Antoviaque

add some rate limiting to the password reset functionality

parent 6bed6f88
...@@ -11,6 +11,7 @@ import unittest ...@@ -11,6 +11,7 @@ import unittest
from datetime import datetime, timedelta from datetime import datetime, timedelta
import pytz import pytz
from django.core.cache import cache
from django.conf import settings from django.conf import settings
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
...@@ -89,6 +90,23 @@ class ResetPasswordTests(TestCase): ...@@ -89,6 +90,23 @@ class ResetPasswordTests(TestCase):
'value': "('registration/password_reset_done.html', [])", 'value': "('registration/password_reset_done.html', [])",
}) })
@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
def test_password_reset_ratelimited(self):
""" Try (and fail) resetting password 30 times in a row on an non-existant email address """
cache.clear()
for i in xrange(30):
good_req = self.request_factory.post('/password_reset/', {'email': 'thisdoesnotexist@foo.com'})
good_resp = password_reset(good_req)
self.assertEquals(good_resp.status_code, 200)
# then the rate limiter should kick in and give a HttpForbidden response
bad_req = self.request_factory.post('/password_reset/', {'email': 'thisdoesnotexist@foo.com'})
bad_resp = password_reset(bad_req)
self.assertEquals(bad_resp.status_code, 403)
cache.clear()
@unittest.skipIf( @unittest.skipIf(
settings.FEATURES.get('DISABLE_RESET_EMAIL_TEST', False), settings.FEATURES.get('DISABLE_RESET_EMAIL_TEST', False),
dedent(""" dedent("""
......
...@@ -70,6 +70,7 @@ import track.views ...@@ -70,6 +70,7 @@ import track.views
from dogapi import dog_stats_api from dogapi import dog_stats_api
from util.json_request import JsonResponse from util.json_request import JsonResponse
from util.bad_request_rate_limiter import BadRequestRateLimiter
from microsite_configuration.middleware import MicrositeConfiguration from microsite_configuration.middleware import MicrositeConfiguration
...@@ -84,7 +85,6 @@ AUDIT_LOG = logging.getLogger("audit") ...@@ -84,7 +85,6 @@ AUDIT_LOG = logging.getLogger("audit")
Article = namedtuple('Article', 'title url author image deck publication publish_date') Article = namedtuple('Article', 'title url author image deck publication publish_date')
ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display') # pylint: disable=C0103 ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display') # pylint: disable=C0103
def csrf_token(context): def csrf_token(context):
"""A csrf token that can be included in a form.""" """A csrf token that can be included in a form."""
csrf_token = context.get('csrf_token', '') csrf_token = context.get('csrf_token', '')
...@@ -1323,12 +1323,23 @@ def password_reset(request): ...@@ -1323,12 +1323,23 @@ def password_reset(request):
if request.method != "POST": if request.method != "POST":
raise Http404 raise Http404
# Add some rate limiting here by re-using the RateLimitMixin as a helper class
limiter = BadRequestRateLimiter()
if limiter.is_rated_limit_exceeded(request):
AUDIT_LOG.warning("Rate limit exceeded in password_reset")
return HttpResponseForbidden()
form = PasswordResetFormNoActive(request.POST) form = PasswordResetFormNoActive(request.POST)
if form.is_valid(): if form.is_valid():
form.save(use_https=request.is_secure(), form.save(use_https=request.is_secure(),
from_email=settings.DEFAULT_FROM_EMAIL, from_email=settings.DEFAULT_FROM_EMAIL,
request=request, request=request,
domain_override=request.get_host()) domain_override=request.get_host())
else:
# bad user? tick the rate limiter counter
AUDIT_LOG.info("Bad password_reset user passed in.")
limiter.tick_bad_request_counter(request)
return JsonResponse({ return JsonResponse({
'success': True, 'success': True,
'value': render_to_string('registration/password_reset_done.html', {}), 'value': render_to_string('registration/password_reset_done.html', {}),
......
"""
A utility class which wraps the RateLimitMixin 3rd party class to do bad request counting
which can be used for rate limiting
"""
from ratelimitbackend.backends import RateLimitMixin
class BadRequestRateLimiter(RateLimitMixin):
"""
Use the 3rd party RateLimitMixin to help do rate limiting on the Password Reset flows
"""
def is_rated_limit_exceeded(self, request):
"""
Returns if the client has been rated limited
"""
counts = self.get_counters(request)
return sum(counts.values()) >= self.requests
def tick_bad_request_counter(self, request):
"""
Ticks any counters used to compute when rate limt has been reached
"""
self.cache_incr(self.get_cache_key(request))
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment