Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions common/djangoapps/student/tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from datetime import datetime, timedelta
import pytz

from django.core.cache import cache
from django.conf import settings
from django.test import TestCase
from django.test.utils import override_settings
Expand Down Expand Up @@ -89,6 +90,23 @@ def test_nonexist_email_password_reset(self):
'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(
settings.FEATURES.get('DISABLE_RESET_EMAIL_TEST', False),
dedent("""
Expand Down
13 changes: 12 additions & 1 deletion common/djangoapps/student/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
from dogapi import dog_stats_api

from util.json_request import JsonResponse
from util.bad_request_rate_limiter import BadRequestRateLimiter

from microsite_configuration.middleware import MicrositeConfiguration

Expand All @@ -86,7 +87,6 @@
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


def csrf_token(context):
"""A csrf token that can be included in a form."""
csrf_token = context.get('csrf_token', '')
Expand Down Expand Up @@ -1345,12 +1345,23 @@ def password_reset(request):
if request.method != "POST":
raise Http404

# Add some rate limiting here by re-using the RateLimitMixin as a helper class
limiter = BadRequestRateLimiter()
if limiter.is_rate_limit_exceeded(request):
AUDIT_LOG.warning("Rate limit exceeded in password_reset")
return HttpResponseForbidden()

form = PasswordResetFormNoActive(request.POST)
if form.is_valid():
form.save(use_https=request.is_secure(),
from_email=settings.DEFAULT_FROM_EMAIL,
request=request,
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({
'success': True,
'value': render_to_string('registration/password_reset_done.html', {}),
Expand Down
23 changes: 23 additions & 0 deletions common/djangoapps/util/bad_request_rate_limiter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""
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_rate_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))