Unverified Commit 3d087d5c by Brian Beggs Committed by GitHub

Merge pull request #15511 from proversity-org/proversity/add-recover-password-endpoint

Add recover password endpoint
parents 622ee7a7 9cf0b335
...@@ -138,6 +138,18 @@ class StudentAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin): ...@@ -138,6 +138,18 @@ class StudentAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin):
self._change_password() self._change_password()
self.assertRaises(UserAPIInternalError) self.assertRaises(UserAPIInternalError)
@ddt.data(
({'email': 'walter@graymattertech.com'}, 200),
({'email': ''}, 400),
({}, 400),
)
@ddt.unpack
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in LMS')
def test_password_recover_api(self, password_recover_info, expected_status):
self.client.logout()
response = self._recover_password_through_api(**password_recover_info)
self.assertEqual(response.status_code, expected_status)
@ddt.data(True, False) @ddt.data(True, False)
def test_password_change_logged_out(self, send_email): def test_password_change_logged_out(self, send_email):
# Log the user out # Log the user out
...@@ -231,6 +243,15 @@ class StudentAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin): ...@@ -231,6 +243,15 @@ class StudentAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin):
return self.client.post(path=reverse('password_change_request'), data=data) return self.client.post(path=reverse('password_change_request'), data=data)
def _recover_password_through_api(self, email=None):
"""Request to change the user's password. """
data = {}
if email:
data['email'] = email
return self.client.post(path=reverse('recover_password_api'), data=data)
def _create_dop_tokens(self, user=None): def _create_dop_tokens(self, user=None):
"""Create dop access token for given user if user provided else for default user.""" """Create dop access token for given user if user provided else for default user."""
if not user: if not user:
......
...@@ -10,5 +10,14 @@ urlpatterns = [ ...@@ -10,5 +10,14 @@ urlpatterns = [
if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'): if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'):
urlpatterns += [ urlpatterns += [
url(r'^password$', views.password_change_request_handler, name='password_change_request'), url(
r'^password$',
views.password_change_request_handler,
name='password_change_request'
),
url(
r'^recover-password$',
views.RecoverPasswordView.as_view(),
name="recover_password_api"
),
] ]
...@@ -8,6 +8,7 @@ from datetime import datetime ...@@ -8,6 +8,7 @@ from datetime import datetime
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
...@@ -34,10 +35,12 @@ from openedx.core.djangoapps.user_api.api import ( ...@@ -34,10 +35,12 @@ from openedx.core.djangoapps.user_api.api import (
get_login_session_form, get_login_session_form,
get_password_reset_form get_password_reset_form
) )
from openedx.core.djangoapps.user_api.errors import ( from openedx.core.djangoapps.user_api.errors import (
UserNotFound, UserNotFound,
UserAPIInternalError UserAPIInternalError
) )
from openedx.core.lib.edx_api_utils import get_edx_api_data from openedx.core.lib.edx_api_utils import get_edx_api_data
from openedx.core.lib.time_zone_utils import TIME_ZONE_CHOICES from openedx.core.lib.time_zone_utils import TIME_ZONE_CHOICES
from openedx.features.enterprise_support.api import enterprise_customer_for_request from openedx.features.enterprise_support.api import enterprise_customer_for_request
...@@ -50,6 +53,11 @@ from third_party_auth.decorators import xframe_allow_whitelisted ...@@ -50,6 +53,11 @@ from third_party_auth.decorators import xframe_allow_whitelisted
from util.bad_request_rate_limiter import BadRequestRateLimiter from util.bad_request_rate_limiter import BadRequestRateLimiter
from util.date_utils import strftime_localized from util.date_utils import strftime_localized
from rest_framework.response import Response
from rest_framework import status
from rest_framework import views
from rest_framework.decorators import api_view
AUDIT_LOG = logging.getLogger("audit") AUDIT_LOG = logging.getLogger("audit")
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
User = get_user_model() # pylint:disable=invalid-name User = get_user_model() # pylint:disable=invalid-name
...@@ -173,6 +181,7 @@ def login_and_registration_form(request, initial_mode="login"): ...@@ -173,6 +181,7 @@ def login_and_registration_form(request, initial_mode="login"):
@require_http_methods(['POST']) @require_http_methods(['POST'])
@api_view(['POST'])
def password_change_request_handler(request): def password_change_request_handler(request):
"""Handle password change requests originating from the account page. """Handle password change requests originating from the account page.
...@@ -198,31 +207,23 @@ def password_change_request_handler(request): ...@@ -198,31 +207,23 @@ def password_change_request_handler(request):
""" """
limiter = BadRequestRateLimiter()
if limiter.is_rate_limit_exceeded(request):
AUDIT_LOG.warning("Password reset rate limit exceeded")
return HttpResponseForbidden()
user = request.user user = request.user
# Prefer logged-in user's email # Prefer logged-in user's email
email = user.email if user.is_authenticated() else request.POST.get('email') email =\
user.email if user.is_authenticated() else request.POST.get('email')
error_message =\
_("Some error occured during password change. Please try again")
if email: if email:
try: status_response = change_password(request, email)
request_password_change(email, request.is_secure()) if status_response == 500:
user = user if user.is_authenticated() else User.objects.get(email=email) return HttpResponse(error_message, status=status_response)
destroy_oauth_tokens(user) elif status_response == 403:
except UserNotFound: return HttpResponseForbidden()
AUDIT_LOG.info("Invalid password reset attempt")
# Increment the rate limit counter return HttpResponse(status=status_response)
limiter.tick_bad_request_counter(request)
except UserAPIInternalError as err:
log.exception('Error occured during password change for user {email}: {error}'
.format(email=email, error=err))
return HttpResponse(_("Some error occured during password change. Please try again"), status=500)
return HttpResponse(status=200)
else:
return HttpResponseBadRequest(_("No email address provided.")) return HttpResponseBadRequest(_("No email address provided."))
...@@ -595,3 +596,66 @@ def account_settings_context(request): ...@@ -595,3 +596,66 @@ def account_settings_context(request):
} for state in auth_states if state.provider.display_for_login or state.has_account] } for state in auth_states if state.provider.display_for_login or state.has_account]
return context return context
class RecoverPasswordView(views.APIView):
"""
Resets a password of a user by proving the email address
Example Requests:
POST /account/recover-password
{"email": "staff@example.com"}
Response Values:
HttpResponse: 200 if the password was reset correctly
HttpResponse: 400 if email wasn't provided
HttpResponse: 403 if the client has been rate limited
HttpResponse: 405 if using an unsupported HTTP method
"""
def post(self, request, *args, **kwargs):
"""
Makes the request to change the password
"""
email = request.data.get('email')
if email:
return Response(status=change_password(request, email))
return Response(status=status.HTTP_400_BAD_REQUEST)
def change_password(request, email):
"""
Changes user's password by providing email.
Args:
request (HTTPRequest): The request to change user's email
email (String): The user email
Returns:
status code of Response
Options:
- 200
- 403
- 500
"""
limiter = BadRequestRateLimiter()
if limiter.is_rate_limit_exceeded(request):
AUDIT_LOG.warning("Password reset rate limit exceeded")
return status.HTTP_403_FORBIDDEN
try:
user = User.objects.get(email=email)
request_password_change(email, request.is_secure())
destroy_oauth_tokens(user)
except User.DoesNotExist:
AUDIT_LOG.info("Invalid password reset attempt")
# Increment the rate limit counter
limiter.tick_bad_request_counter(request)
except UserAPIInternalError as err:
log.exception(
'Error occured during password change for user {email}: {error}'
.format(email=email, error=err)
)
return status.HTTP_500_INTERNAL_SERVER_ERROR
return status.HTTP_200_OK
...@@ -375,7 +375,6 @@ def request_password_change(email, is_secure): ...@@ -375,7 +375,6 @@ def request_password_change(email, is_secure):
Args: Args:
email (str): An email address email (str): An email address
orig_host (str): An originating host, extracted from a request with get_host
is_secure (bool): Whether the request was made with HTTPS is_secure (bool): Whether the request was made with HTTPS
Returns: Returns:
......
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