Move password reset logic from student to user_authn.
This commit is contained in:
@@ -6,7 +6,6 @@ from __future__ import absolute_import
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls import url
|
||||
from django.contrib.auth.views import password_reset_complete
|
||||
|
||||
from . import views
|
||||
|
||||
@@ -22,15 +21,6 @@ urlpatterns = [
|
||||
url(r'^change_setting$', views.change_setting, name='change_setting'),
|
||||
url(r'^change_email_settings$', views.change_email_settings, name='change_email_settings'),
|
||||
|
||||
# password reset in views (see below for password reset django views)
|
||||
url(r'^account/password$', views.password_change_request_handler, name='password_change_request'),
|
||||
url(r'^password_reset/$', views.password_reset, name='password_reset'),
|
||||
url(
|
||||
r'^password_reset_confirm/(?P<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/$',
|
||||
views.password_reset_confirm_wrapper,
|
||||
name='password_reset_confirm',
|
||||
),
|
||||
|
||||
url(r'^course_run/{}/refund_status$'.format(settings.COURSE_ID_PATTERN),
|
||||
views.course_run_refund_status,
|
||||
name="course_run_refund_status"),
|
||||
@@ -42,13 +32,3 @@ urlpatterns = [
|
||||
),
|
||||
|
||||
]
|
||||
|
||||
# password reset django views (see above for password reset views)
|
||||
urlpatterns += [
|
||||
# TODO: Replace with Mako-ized views
|
||||
url(
|
||||
r'^password_reset_complete/$',
|
||||
password_reset_complete,
|
||||
name='password_reset_complete',
|
||||
),
|
||||
]
|
||||
|
||||
@@ -60,7 +60,6 @@ from openedx.core.djangoapps.theming import helpers as theming_helpers
|
||||
from openedx.core.djangoapps.theming.helpers import get_current_site
|
||||
from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled
|
||||
from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, SYSTEM_MAINTENANCE_MSG, waffle
|
||||
from openedx.core.djangoapps.user_api.errors import UserAPIInternalError, UserNotFound
|
||||
from openedx.core.djangoapps.user_api.models import UserRetirementRequest
|
||||
from openedx.core.djangoapps.user_api.preferences import api as preferences_api
|
||||
from openedx.core.djangoapps.user_authn.message_types import PasswordReset
|
||||
@@ -635,332 +634,6 @@ def activate_account_studio(request, key):
|
||||
)
|
||||
|
||||
|
||||
@require_http_methods(['POST'])
|
||||
def password_change_request_handler(request):
|
||||
"""Handle password change requests originating from the account page.
|
||||
|
||||
Uses the Account API to email the user a link to the password reset page.
|
||||
|
||||
Note:
|
||||
The next step in the password reset process (confirmation) is currently handled
|
||||
by student.views.password_reset_confirm_wrapper, a custom wrapper around Django's
|
||||
password reset confirmation view.
|
||||
|
||||
Args:
|
||||
request (HttpRequest)
|
||||
|
||||
Returns:
|
||||
HttpResponse: 200 if the email was sent successfully
|
||||
HttpResponse: 400 if there is no 'email' POST parameter
|
||||
HttpResponse: 403 if the client has been rate limited
|
||||
HttpResponse: 405 if using an unsupported HTTP method
|
||||
|
||||
Example usage:
|
||||
|
||||
POST /account/password
|
||||
|
||||
"""
|
||||
|
||||
password_reset_email_limiter = PasswordResetEmailRateLimiter()
|
||||
|
||||
if password_reset_email_limiter.is_rate_limit_exceeded(request):
|
||||
AUDIT_LOG.warning("Password reset rate limit exceeded")
|
||||
return HttpResponse(
|
||||
_("Your previous request is in progress, please try again in a few moments."),
|
||||
status=403
|
||||
)
|
||||
|
||||
user = request.user
|
||||
# Prefer logged-in user's email
|
||||
email = user.email if user.is_authenticated else request.POST.get('email')
|
||||
|
||||
if email:
|
||||
try:
|
||||
from openedx.core.djangoapps.user_authn.views.password_reset import request_password_change
|
||||
request_password_change(email, request.is_secure())
|
||||
user = user if user.is_authenticated else get_user_from_email(email=email)
|
||||
destroy_oauth_tokens(user)
|
||||
except UserNotFound:
|
||||
AUDIT_LOG.info("Invalid password reset attempt")
|
||||
# If enabled, send an email saying that a password reset was attempted, but that there is
|
||||
# no user associated with the email
|
||||
if configuration_helpers.get_value('ENABLE_PASSWORD_RESET_FAILURE_EMAIL',
|
||||
settings.FEATURES['ENABLE_PASSWORD_RESET_FAILURE_EMAIL']):
|
||||
site = get_current_site()
|
||||
message_context = get_base_template_context(site)
|
||||
|
||||
message_context.update({
|
||||
'failed': True,
|
||||
'request': request, # Used by google_analytics_tracking_pixel
|
||||
'email_address': email,
|
||||
})
|
||||
|
||||
msg = PasswordReset().personalize(
|
||||
recipient=Recipient(username='', email_address=email),
|
||||
language=settings.LANGUAGE_CODE,
|
||||
user_context=message_context,
|
||||
)
|
||||
ace.send(msg)
|
||||
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)
|
||||
|
||||
password_reset_email_limiter.tick_request_counter(request)
|
||||
return HttpResponse(status=200)
|
||||
else:
|
||||
return HttpResponseBadRequest(_("No email address provided."))
|
||||
|
||||
|
||||
def get_user_from_email(email):
|
||||
"""
|
||||
Find a user using given email and return it.
|
||||
|
||||
Arguments:
|
||||
email (str): primary or secondary email address of the user.
|
||||
|
||||
Raises:
|
||||
(User.ObjectNotFound): If no user is found with the given email.
|
||||
(User.MultipleObjectsReturned): If more than one user is found with the given email.
|
||||
|
||||
Returns:
|
||||
User: Django user object.
|
||||
"""
|
||||
try:
|
||||
return User.objects.get(email=email)
|
||||
except ObjectDoesNotExist:
|
||||
return User.objects.filter(
|
||||
id__in=AccountRecovery.objects.filter(secondary_email__iexact=email, is_active=True).values_list('user')
|
||||
).get()
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def password_reset(request):
|
||||
"""
|
||||
Attempts to send a password reset e-mail.
|
||||
"""
|
||||
# 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()
|
||||
|
||||
from openedx.core.djangoapps.user_authn.views.password_reset import PasswordResetFormNoActive
|
||||
form = PasswordResetFormNoActive(request.POST)
|
||||
if form.is_valid():
|
||||
form.save(use_https=request.is_secure(),
|
||||
from_email=configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL),
|
||||
request=request)
|
||||
# When password change is complete, a "edx.user.settings.changed" event will be emitted.
|
||||
# But because changing the password is multi-step, we also emit an event here so that we can
|
||||
# track where the request was initiated.
|
||||
tracker.emit(
|
||||
SETTING_CHANGE_INITIATED,
|
||||
{
|
||||
"setting": "password",
|
||||
"old": None,
|
||||
"new": None,
|
||||
"user_id": request.user.id,
|
||||
}
|
||||
)
|
||||
destroy_oauth_tokens(request.user)
|
||||
else:
|
||||
# bad user? tick the rate limiter counter
|
||||
AUDIT_LOG.info("Bad password_reset user passed in.")
|
||||
limiter.tick_request_counter(request)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'value': render_to_string('registration/password_reset_done.html', {}),
|
||||
})
|
||||
|
||||
|
||||
def uidb36_to_uidb64(uidb36):
|
||||
"""
|
||||
Needed to support old password reset URLs that use base36-encoded user IDs
|
||||
https://github.com/django/django/commit/1184d077893ff1bc947e45b00a4d565f3df81776#diff-c571286052438b2e3190f8db8331a92bR231
|
||||
Args:
|
||||
uidb36: base36-encoded user ID
|
||||
|
||||
Returns: base64-encoded user ID. Otherwise returns a dummy, invalid ID
|
||||
"""
|
||||
try:
|
||||
uidb64 = force_text(urlsafe_base64_encode(force_bytes(base36_to_int(uidb36))))
|
||||
except ValueError:
|
||||
uidb64 = '1' # dummy invalid ID (incorrect padding for base64)
|
||||
return uidb64
|
||||
|
||||
|
||||
def password_reset_confirm_wrapper(request, uidb36=None, token=None):
|
||||
"""
|
||||
A wrapper around django.contrib.auth.views.password_reset_confirm.
|
||||
Needed because we want to set the user as active at this step.
|
||||
We also optionally do some additional password policy checks.
|
||||
"""
|
||||
# convert old-style base36-encoded user id to base64
|
||||
uidb64 = uidb36_to_uidb64(uidb36)
|
||||
platform_name = {
|
||||
"platform_name": configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME)
|
||||
}
|
||||
|
||||
# User can not get this link unless account recovery feature is enabled.
|
||||
if 'is_account_recovery' in request.GET and not is_secondary_email_feature_enabled():
|
||||
raise Http404
|
||||
|
||||
try:
|
||||
uid_int = base36_to_int(uidb36)
|
||||
if request.user.is_authenticated and request.user.id != uid_int:
|
||||
raise Http404
|
||||
|
||||
user = User.objects.get(id=uid_int)
|
||||
except (ValueError, User.DoesNotExist):
|
||||
# if there's any error getting a user, just let django's
|
||||
# password_reset_confirm function handle it.
|
||||
return password_reset_confirm(
|
||||
request, uidb64=uidb64, token=token, extra_context=platform_name
|
||||
)
|
||||
|
||||
if UserRetirementRequest.has_user_requested_retirement(user):
|
||||
# Refuse to reset the password of any user that has requested retirement.
|
||||
context = {
|
||||
'validlink': True,
|
||||
'form': None,
|
||||
'title': _('Password reset unsuccessful'),
|
||||
'err_msg': _('Error in resetting your password.'),
|
||||
}
|
||||
context.update(platform_name)
|
||||
return TemplateResponse(
|
||||
request, 'registration/password_reset_confirm.html', context
|
||||
)
|
||||
|
||||
if waffle().is_enabled(PREVENT_AUTH_USER_WRITES):
|
||||
context = {
|
||||
'validlink': False,
|
||||
'form': None,
|
||||
'title': _('Password reset unsuccessful'),
|
||||
'err_msg': SYSTEM_MAINTENANCE_MSG,
|
||||
}
|
||||
context.update(platform_name)
|
||||
return TemplateResponse(
|
||||
request, 'registration/password_reset_confirm.html', context
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
# We have to make a copy of request.POST because it is a QueryDict object which is immutable until copied.
|
||||
# We have to use request.POST because the password_reset_confirm method takes in the request and a user's
|
||||
# password is set to the request.POST['new_password1'] field. We have to also normalize the new_password2
|
||||
# field so it passes the equivalence check that new_password1 == new_password2
|
||||
# In order to switch out of having to do this copy, we would want to move the normalize_password code into
|
||||
# a custom User model's set_password method to ensure it is always happening upon calling set_password.
|
||||
request.POST = request.POST.copy()
|
||||
request.POST['new_password1'] = normalize_password(request.POST['new_password1'])
|
||||
request.POST['new_password2'] = normalize_password(request.POST['new_password2'])
|
||||
|
||||
password = request.POST['new_password1']
|
||||
|
||||
try:
|
||||
validate_password(password, user=user)
|
||||
except ValidationError as err:
|
||||
# We have a password reset attempt which violates some security
|
||||
# policy, or any other validation. Use the existing Django template to communicate that
|
||||
# back to the user.
|
||||
context = {
|
||||
'validlink': True,
|
||||
'form': None,
|
||||
'title': _('Password reset unsuccessful'),
|
||||
'err_msg': ' '.join(err.messages),
|
||||
}
|
||||
context.update(platform_name)
|
||||
return TemplateResponse(
|
||||
request, 'registration/password_reset_confirm.html', context
|
||||
)
|
||||
|
||||
# remember what the old password hash is before we call down
|
||||
old_password_hash = user.password
|
||||
|
||||
if 'is_account_recovery' in request.GET:
|
||||
response = password_reset_confirm(
|
||||
request,
|
||||
uidb64=uidb64,
|
||||
token=token,
|
||||
extra_context=platform_name,
|
||||
template_name='registration/password_reset_confirm.html',
|
||||
post_reset_redirect='signin_user',
|
||||
)
|
||||
else:
|
||||
response = password_reset_confirm(
|
||||
request, uidb64=uidb64, token=token, extra_context=platform_name
|
||||
)
|
||||
|
||||
# If password reset was unsuccessful a template response is returned (status_code 200).
|
||||
# Check if form is invalid then show an error to the user.
|
||||
# Note if password reset was successful we get response redirect (status_code 302).
|
||||
if response.status_code == 200:
|
||||
form_valid = response.context_data['form'].is_valid() if response.context_data['form'] else False
|
||||
if not form_valid:
|
||||
log.warning(
|
||||
u'Unable to reset password for user [%s] because form is not valid. '
|
||||
u'A possible cause is that the user had an invalid reset token',
|
||||
user.username,
|
||||
)
|
||||
response.context_data['err_msg'] = _('Error in resetting your password. Please try again.')
|
||||
return response
|
||||
|
||||
# get the updated user
|
||||
updated_user = User.objects.get(id=uid_int)
|
||||
if 'is_account_recovery' in request.GET:
|
||||
try:
|
||||
updated_user.email = updated_user.account_recovery.secondary_email
|
||||
updated_user.account_recovery.delete()
|
||||
# emit an event that the user changed their secondary email to the primary email
|
||||
tracker.emit(
|
||||
SETTING_CHANGE_INITIATED,
|
||||
{
|
||||
"setting": "email",
|
||||
"old": user.email,
|
||||
"new": updated_user.email,
|
||||
"user_id": updated_user.id,
|
||||
}
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
log.error(
|
||||
'Account recovery process initiated without AccountRecovery instance for user {username}'.format(
|
||||
username=updated_user.username
|
||||
)
|
||||
)
|
||||
|
||||
updated_user.save()
|
||||
|
||||
if response.status_code == 302 and 'is_account_recovery' in request.GET:
|
||||
messages.success(
|
||||
request,
|
||||
HTML(_(
|
||||
'{html_start}Password Creation Complete{html_end}'
|
||||
'Your password has been created. {bold_start}{email}{bold_end} is now your primary login email.'
|
||||
)).format(
|
||||
support_url=configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK),
|
||||
html_start=HTML('<p class="message-title">'),
|
||||
html_end=HTML('</p>'),
|
||||
bold_start=HTML('<b>'),
|
||||
bold_end=HTML('</b>'),
|
||||
email=updated_user.email,
|
||||
),
|
||||
extra_tags='account-recovery aa-icon submission-success'
|
||||
)
|
||||
else:
|
||||
response = password_reset_confirm(
|
||||
request, uidb64=uidb64, token=token, extra_context=platform_name
|
||||
)
|
||||
|
||||
response_was_successful = response.context_data.get('validlink')
|
||||
if response_was_successful and not user.is_active:
|
||||
user.is_active = True
|
||||
user.save()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def validate_new_email(user, new_email):
|
||||
"""
|
||||
Given a new email for a user, does some basic verification of the new address If any issues are encountered
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"""
|
||||
This file will test through the LMS some of the password reset features
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import ddt
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
from django.test.utils import override_settings
|
||||
from django.utils.http import int_to_base36
|
||||
|
||||
from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase
|
||||
from util.password_policy_validators import create_validator_config
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestPasswordReset(LoginEnrollmentTestCase):
|
||||
"""
|
||||
Go through some of the password reset use cases
|
||||
"""
|
||||
|
||||
def _setup_user(self, is_staff=False, password=None):
|
||||
"""
|
||||
Override the base implementation to randomize the email
|
||||
"""
|
||||
email = 'foo_{0}@test.com'.format(uuid4().hex[:8])
|
||||
password = password if password else 'foo'
|
||||
username = 'test_{0}'.format(uuid4().hex[:8])
|
||||
self.create_account(username, email, password)
|
||||
self.activate_user(email)
|
||||
|
||||
# manually twiddle the is_staff bit, if needed
|
||||
if is_staff:
|
||||
user = User.objects.get(email=email)
|
||||
user.is_staff = True
|
||||
user.save()
|
||||
|
||||
return email, password
|
||||
|
||||
def assertPasswordResetError(self, response, error_message, valid_link=True):
|
||||
"""
|
||||
This method is a custom assertion that verifies that a password reset
|
||||
view returns an error response as expected.
|
||||
Args:
|
||||
response: response from calling a password reset endpoint
|
||||
error_message: message we expect to see in the response
|
||||
valid_link: if the current password reset link is still valid
|
||||
|
||||
"""
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context_data['validlink'], valid_link)
|
||||
self.assertContains(response, error_message)
|
||||
|
||||
@override_settings(AUTH_PASSWORD_VALIDATORS=[
|
||||
create_validator_config('util.password_policy_validators.MinimumLengthValidator', {'min_length': 6})
|
||||
])
|
||||
def test_password_policy_on_password_reset(self):
|
||||
"""
|
||||
This makes sure the proper asserts on password policy also works on password reset
|
||||
"""
|
||||
staff_email, _ = self._setup_user(is_staff=True, password='foofoo')
|
||||
|
||||
success_msg = 'Your Password Reset is Complete'
|
||||
|
||||
# try to reset password, it should fail
|
||||
user = User.objects.get(email=staff_email)
|
||||
token = default_token_generator.make_token(user)
|
||||
uidb36 = int_to_base36(user.id)
|
||||
|
||||
# try to do a password reset with the same password as before
|
||||
resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), {
|
||||
'new_password1': 'foo',
|
||||
'new_password2': 'foo',
|
||||
}, follow=True)
|
||||
|
||||
self.assertNotContains(resp, success_msg)
|
||||
|
||||
# try to reset password with a long enough password
|
||||
user = User.objects.get(email=staff_email)
|
||||
token = default_token_generator.make_token(user)
|
||||
uidb36 = int_to_base36(user.id)
|
||||
|
||||
# try to do a password reset with the same password as before
|
||||
resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), {
|
||||
'new_password1': 'foofoo',
|
||||
'new_password2': 'foofoo',
|
||||
}, follow=True)
|
||||
|
||||
self.assertContains(resp, success_msg)
|
||||
|
||||
@ddt.data(
|
||||
('foo', 'foobar', 'Error in resetting your password. Please try again.'),
|
||||
('', '', 'This password is too short. It must contain at least'),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_password_reset_form_invalid(self, password1, password2, err_msg):
|
||||
"""
|
||||
Tests that password reset fail when providing bad passwords and error message is displayed
|
||||
to the user.
|
||||
"""
|
||||
user_email, _ = self._setup_user()
|
||||
|
||||
# try to reset password, it should fail
|
||||
user = User.objects.get(email=user_email)
|
||||
token = default_token_generator.make_token(user)
|
||||
uidb36 = int_to_base36(user.id)
|
||||
|
||||
# try to do a password reset with the same password as before
|
||||
resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), {
|
||||
'new_password1': password1,
|
||||
'new_password2': password2,
|
||||
}, follow=True)
|
||||
self.assertPasswordResetError(resp, err_msg)
|
||||
@@ -13,6 +13,7 @@ from __future__ import absolute_import
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls import url
|
||||
from django.contrib.auth.views import password_reset_complete
|
||||
|
||||
from .views import auto_auth, login, logout, password_reset, register
|
||||
|
||||
@@ -53,8 +54,25 @@ urlpatterns = [
|
||||
url(r'^user_api/v1/account/password_reset/$', password_reset.PasswordResetView.as_view(),
|
||||
name="user_api_password_reset"),
|
||||
|
||||
# Password reset api views.
|
||||
url(r'^password_reset/$', password_reset.password_reset, name='password_reset'),
|
||||
url(
|
||||
r'^password_reset_confirm/(?P<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/$',
|
||||
password_reset.password_reset_confirm_wrapper,
|
||||
name='password_reset_confirm',
|
||||
),
|
||||
url(r'^account/password$', password_reset.password_change_request_handler, name='password_change_request'),
|
||||
|
||||
]
|
||||
|
||||
# password reset django views (see above for password reset views)
|
||||
urlpatterns += [
|
||||
url(
|
||||
r'^password_reset_complete/$',
|
||||
password_reset_complete,
|
||||
name='password_reset_complete',
|
||||
),
|
||||
]
|
||||
|
||||
# enable automatic login
|
||||
if settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING'):
|
||||
|
||||
@@ -1,37 +1,62 @@
|
||||
""" Handles password resets logic. """
|
||||
""" Password reset logic and views . """
|
||||
|
||||
import logging
|
||||
from django import forms
|
||||
|
||||
from django.contrib.auth.forms import PasswordResetForm
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
from django.http import HttpResponse
|
||||
from django.contrib.auth.views import password_reset_confirm
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.validators import ValidationError
|
||||
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
|
||||
from django.template.response import TemplateResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.http import int_to_base36
|
||||
from django.utils.encoding import force_bytes, force_text
|
||||
from django.utils.http import base36_to_int, int_to_base36, urlsafe_base64_encode
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.urls import reverse
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from edx_ace import ace
|
||||
from edx_ace.recipient import Recipient
|
||||
from edxmako.shortcuts import render_to_string
|
||||
from eventtracking import tracker
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from openedx.core.djangoapps.ace_common.template_context import get_base_template_context
|
||||
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
|
||||
from openedx.core.djangoapps.oauth_dispatch.api import destroy_oauth_tokens
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangoapps.theming.helpers import get_current_request, get_current_site
|
||||
from openedx.core.djangoapps.user_api import accounts, errors, helpers
|
||||
from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled
|
||||
from openedx.core.djangoapps.user_api.helpers import FormDescription
|
||||
from openedx.core.djangoapps.user_api.models import UserRetirementRequest
|
||||
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
|
||||
from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, SYSTEM_MAINTENANCE_MSG, waffle
|
||||
from openedx.core.djangoapps.user_authn.message_types import PasswordReset
|
||||
from openedx.core.djangolib.markup import HTML
|
||||
|
||||
from student.models import AccountRecovery
|
||||
from student.forms import send_account_recovery_email_for_user
|
||||
|
||||
from util.json_request import JsonResponse
|
||||
from util.password_policy_validators import normalize_password, validate_password
|
||||
from util.request_rate_limiter import BadRequestRateLimiter, PasswordResetEmailRateLimiter
|
||||
|
||||
|
||||
SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated'
|
||||
|
||||
# Maintaining this naming for backwards compatibility.
|
||||
log = logging.getLogger("edx.student")
|
||||
AUDIT_LOG = logging.getLogger("audit")
|
||||
|
||||
|
||||
def get_password_reset_form():
|
||||
"""Return a description of the password reset form.
|
||||
@@ -215,3 +240,325 @@ def request_password_change(email, is_secure):
|
||||
else:
|
||||
# No user with the provided email address exists.
|
||||
raise errors.UserNotFound
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def password_reset(request):
|
||||
"""
|
||||
Attempts to send a password reset e-mail.
|
||||
"""
|
||||
# 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=configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL),
|
||||
request=request)
|
||||
# When password change is complete, a "edx.user.settings.changed" event will be emitted.
|
||||
# But because changing the password is multi-step, we also emit an event here so that we can
|
||||
# track where the request was initiated.
|
||||
tracker.emit(
|
||||
SETTING_CHANGE_INITIATED,
|
||||
{
|
||||
"setting": "password",
|
||||
"old": None,
|
||||
"new": None,
|
||||
"user_id": request.user.id,
|
||||
}
|
||||
)
|
||||
destroy_oauth_tokens(request.user)
|
||||
else:
|
||||
# bad user? tick the rate limiter counter
|
||||
AUDIT_LOG.info("Bad password_reset user passed in.")
|
||||
limiter.tick_request_counter(request)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'value': render_to_string('registration/password_reset_done.html', {}),
|
||||
})
|
||||
|
||||
|
||||
def _uidb36_to_uidb64(uidb36):
|
||||
"""
|
||||
Needed to support old password reset URLs that use base36-encoded user IDs
|
||||
https://github.com/django/django/commit/1184d077893ff1bc947e45b00a4d565f3df81776#diff-c571286052438b2e3190f8db8331a92bR231
|
||||
Args:
|
||||
uidb36: base36-encoded user ID
|
||||
|
||||
Returns: base64-encoded user ID. Otherwise returns a dummy, invalid ID
|
||||
"""
|
||||
try:
|
||||
uidb64 = force_text(urlsafe_base64_encode(force_bytes(base36_to_int(uidb36))))
|
||||
except ValueError:
|
||||
uidb64 = '1' # dummy invalid ID (incorrect padding for base64)
|
||||
return uidb64
|
||||
|
||||
|
||||
# pylint: disable=too-many-statements
|
||||
def password_reset_confirm_wrapper(request, uidb36=None, token=None):
|
||||
"""
|
||||
A wrapper around django.contrib.auth.views.password_reset_confirm.
|
||||
Needed because we want to set the user as active at this step.
|
||||
We also optionally do some additional password policy checks.
|
||||
"""
|
||||
# convert old-style base36-encoded user id to base64
|
||||
uidb64 = _uidb36_to_uidb64(uidb36)
|
||||
platform_name = {
|
||||
"platform_name": configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME)
|
||||
}
|
||||
|
||||
# User can not get this link unless account recovery feature is enabled.
|
||||
if 'is_account_recovery' in request.GET and not is_secondary_email_feature_enabled():
|
||||
raise Http404
|
||||
|
||||
try:
|
||||
uid_int = base36_to_int(uidb36)
|
||||
if request.user.is_authenticated and request.user.id != uid_int:
|
||||
raise Http404
|
||||
|
||||
user = User.objects.get(id=uid_int)
|
||||
except (ValueError, User.DoesNotExist):
|
||||
# if there's any error getting a user, just let django's
|
||||
# password_reset_confirm function handle it.
|
||||
return password_reset_confirm(
|
||||
request, uidb64=uidb64, token=token, extra_context=platform_name
|
||||
)
|
||||
|
||||
if UserRetirementRequest.has_user_requested_retirement(user):
|
||||
# Refuse to reset the password of any user that has requested retirement.
|
||||
context = {
|
||||
'validlink': True,
|
||||
'form': None,
|
||||
'title': _('Password reset unsuccessful'),
|
||||
'err_msg': _('Error in resetting your password.'),
|
||||
}
|
||||
context.update(platform_name)
|
||||
return TemplateResponse(
|
||||
request, 'registration/password_reset_confirm.html', context
|
||||
)
|
||||
|
||||
if waffle().is_enabled(PREVENT_AUTH_USER_WRITES):
|
||||
context = {
|
||||
'validlink': False,
|
||||
'form': None,
|
||||
'title': _('Password reset unsuccessful'),
|
||||
'err_msg': SYSTEM_MAINTENANCE_MSG,
|
||||
}
|
||||
context.update(platform_name)
|
||||
return TemplateResponse(
|
||||
request, 'registration/password_reset_confirm.html', context
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
# We have to make a copy of request.POST because it is a QueryDict object which is immutable until copied.
|
||||
# We have to use request.POST because the password_reset_confirm method takes in the request and a user's
|
||||
# password is set to the request.POST['new_password1'] field. We have to also normalize the new_password2
|
||||
# field so it passes the equivalence check that new_password1 == new_password2
|
||||
# In order to switch out of having to do this copy, we would want to move the normalize_password code into
|
||||
# a custom User model's set_password method to ensure it is always happening upon calling set_password.
|
||||
request.POST = request.POST.copy()
|
||||
request.POST['new_password1'] = normalize_password(request.POST['new_password1'])
|
||||
request.POST['new_password2'] = normalize_password(request.POST['new_password2'])
|
||||
|
||||
password = request.POST['new_password1']
|
||||
|
||||
try:
|
||||
validate_password(password, user=user)
|
||||
except ValidationError as err:
|
||||
# We have a password reset attempt which violates some security
|
||||
# policy, or any other validation. Use the existing Django template to communicate that
|
||||
# back to the user.
|
||||
context = {
|
||||
'validlink': True,
|
||||
'form': None,
|
||||
'title': _('Password reset unsuccessful'),
|
||||
'err_msg': ' '.join(err.messages),
|
||||
}
|
||||
context.update(platform_name)
|
||||
return TemplateResponse(
|
||||
request, 'registration/password_reset_confirm.html', context
|
||||
)
|
||||
|
||||
if 'is_account_recovery' in request.GET:
|
||||
response = password_reset_confirm(
|
||||
request,
|
||||
uidb64=uidb64,
|
||||
token=token,
|
||||
extra_context=platform_name,
|
||||
template_name='registration/password_reset_confirm.html',
|
||||
post_reset_redirect='signin_user',
|
||||
)
|
||||
else:
|
||||
response = password_reset_confirm(
|
||||
request, uidb64=uidb64, token=token, extra_context=platform_name
|
||||
)
|
||||
|
||||
# If password reset was unsuccessful a template response is returned (status_code 200).
|
||||
# Check if form is invalid then show an error to the user.
|
||||
# Note if password reset was successful we get response redirect (status_code 302).
|
||||
if response.status_code == 200:
|
||||
form_valid = response.context_data['form'].is_valid() if response.context_data['form'] else False
|
||||
if not form_valid:
|
||||
log.warning(
|
||||
u'Unable to reset password for user [%s] because form is not valid. '
|
||||
u'A possible cause is that the user had an invalid reset token',
|
||||
user.username,
|
||||
)
|
||||
response.context_data['err_msg'] = _('Error in resetting your password. Please try again.')
|
||||
return response
|
||||
|
||||
# get the updated user
|
||||
updated_user = User.objects.get(id=uid_int)
|
||||
if 'is_account_recovery' in request.GET:
|
||||
try:
|
||||
updated_user.email = updated_user.account_recovery.secondary_email
|
||||
updated_user.account_recovery.delete()
|
||||
# emit an event that the user changed their secondary email to the primary email
|
||||
tracker.emit(
|
||||
SETTING_CHANGE_INITIATED,
|
||||
{
|
||||
"setting": "email",
|
||||
"old": user.email,
|
||||
"new": updated_user.email,
|
||||
"user_id": updated_user.id,
|
||||
}
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
log.error(
|
||||
u'Account recovery process initiated without AccountRecovery instance for user {username}'.format(
|
||||
username=updated_user.username
|
||||
)
|
||||
)
|
||||
|
||||
updated_user.save()
|
||||
|
||||
if response.status_code == 302 and 'is_account_recovery' in request.GET:
|
||||
messages.success(
|
||||
request,
|
||||
HTML(_(
|
||||
u'{html_start}Password Creation Complete{html_end}'
|
||||
u'Your password has been created. {bold_start}{email}{bold_end} is now your primary login email.'
|
||||
)).format(
|
||||
support_url=configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK),
|
||||
html_start=HTML('<p class="message-title">'),
|
||||
html_end=HTML('</p>'),
|
||||
bold_start=HTML('<b>'),
|
||||
bold_end=HTML('</b>'),
|
||||
email=updated_user.email,
|
||||
),
|
||||
extra_tags='account-recovery aa-icon submission-success'
|
||||
)
|
||||
else:
|
||||
response = password_reset_confirm(
|
||||
request, uidb64=uidb64, token=token, extra_context=platform_name
|
||||
)
|
||||
|
||||
response_was_successful = response.context_data.get('validlink')
|
||||
if response_was_successful and not user.is_active:
|
||||
user.is_active = True
|
||||
user.save()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _get_user_from_email(email):
|
||||
"""
|
||||
Find a user using given email and return it.
|
||||
|
||||
Arguments:
|
||||
email (str): primary or secondary email address of the user.
|
||||
|
||||
Raises:
|
||||
(User.ObjectNotFound): If no user is found with the given email.
|
||||
(User.MultipleObjectsReturned): If more than one user is found with the given email.
|
||||
|
||||
Returns:
|
||||
User: Django user object.
|
||||
"""
|
||||
try:
|
||||
return User.objects.get(email=email)
|
||||
except ObjectDoesNotExist:
|
||||
return User.objects.filter(
|
||||
id__in=AccountRecovery.objects.filter(secondary_email__iexact=email, is_active=True).values_list('user')
|
||||
).get()
|
||||
|
||||
|
||||
@require_POST
|
||||
def password_change_request_handler(request):
|
||||
"""Handle password change requests originating from the account page.
|
||||
|
||||
Uses the Account API to email the user a link to the password reset page.
|
||||
|
||||
Note:
|
||||
The next step in the password reset process (confirmation) is currently handled
|
||||
by student.views.password_reset_confirm_wrapper, a custom wrapper around Django's
|
||||
password reset confirmation view.
|
||||
|
||||
Args:
|
||||
request (HttpRequest)
|
||||
|
||||
Returns:
|
||||
HttpResponse: 200 if the email was sent successfully
|
||||
HttpResponse: 400 if there is no 'email' POST parameter
|
||||
HttpResponse: 403 if the client has been rate limited
|
||||
HttpResponse: 405 if using an unsupported HTTP method
|
||||
|
||||
Example usage:
|
||||
|
||||
POST /account/password
|
||||
|
||||
"""
|
||||
|
||||
password_reset_email_limiter = PasswordResetEmailRateLimiter()
|
||||
|
||||
if password_reset_email_limiter.is_rate_limit_exceeded(request):
|
||||
AUDIT_LOG.warning("Password reset rate limit exceeded")
|
||||
return HttpResponse(
|
||||
_("Your previous request is in progress, please try again in a few moments."),
|
||||
status=403
|
||||
)
|
||||
|
||||
user = request.user
|
||||
# Prefer logged-in user's email
|
||||
email = user.email if user.is_authenticated else request.POST.get('email')
|
||||
|
||||
if email:
|
||||
try:
|
||||
request_password_change(email, request.is_secure())
|
||||
user = user if user.is_authenticated else _get_user_from_email(email=email)
|
||||
destroy_oauth_tokens(user)
|
||||
except errors.UserNotFound:
|
||||
AUDIT_LOG.info("Invalid password reset attempt")
|
||||
# If enabled, send an email saying that a password reset was attempted, but that there is
|
||||
# no user associated with the email
|
||||
if configuration_helpers.get_value('ENABLE_PASSWORD_RESET_FAILURE_EMAIL',
|
||||
settings.FEATURES['ENABLE_PASSWORD_RESET_FAILURE_EMAIL']):
|
||||
site = get_current_site()
|
||||
message_context = get_base_template_context(site)
|
||||
|
||||
message_context.update({
|
||||
'failed': True,
|
||||
'request': request, # Used by google_analytics_tracking_pixel
|
||||
'email_address': email,
|
||||
})
|
||||
|
||||
msg = PasswordReset().personalize(
|
||||
recipient=Recipient(username='', email_address=email),
|
||||
language=settings.LANGUAGE_CODE,
|
||||
user_context=message_context,
|
||||
)
|
||||
ace.send(msg)
|
||||
except errors.UserAPIInternalError as err:
|
||||
log.exception(u'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)
|
||||
|
||||
password_reset_email_limiter.tick_request_counter(request)
|
||||
return HttpResponse(status=200)
|
||||
else:
|
||||
return HttpResponseBadRequest(_("No email address provided."))
|
||||
|
||||
@@ -85,5 +85,5 @@ class TestRequestPasswordChange(CreateAccountMixin, TestCase):
|
||||
with patch('crum.get_current_request', return_value=request):
|
||||
request_password_change(self.EMAIL, self.IS_SECURE)
|
||||
|
||||
# Verify that the activation email was still sent
|
||||
# Verify that the password change email was still sent
|
||||
self.assertEqual(len(mail.outbox), 2)
|
||||
|
||||
@@ -33,11 +33,14 @@ from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRI
|
||||
from openedx.core.djangoapps.user_api.models import UserRetirementRequest
|
||||
from openedx.core.djangoapps.user_api.tests.test_views import UserAPITestCase
|
||||
from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH, EMAIL_MIN_LENGTH
|
||||
from openedx.core.djangoapps.user_authn.views.password_reset import (
|
||||
SETTING_CHANGE_INITIATED, password_reset, password_reset_confirm_wrapper
|
||||
)
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
|
||||
from student.tests.factories import UserFactory
|
||||
from student.tests.test_configuration_overrides import fake_get_value
|
||||
from student.tests.test_email import mock_render_to_string
|
||||
from student.views import SETTING_CHANGE_INITIATED, password_reset, password_reset_confirm_wrapper
|
||||
|
||||
from util.password_policy_validators import create_validator_config
|
||||
from util.testing import EventTestMixin
|
||||
|
||||
@@ -56,7 +59,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
|
||||
ENABLED_CACHES = ['default']
|
||||
|
||||
def setUp(self): # pylint: disable=arguments-differ
|
||||
super(ResetPasswordTests, self).setUp('student.views.management.tracker')
|
||||
super(ResetPasswordTests, self).setUp('openedx.core.djangoapps.user_authn.views.password_reset.tracker')
|
||||
self.user = UserFactory.create()
|
||||
self.user.is_active = False
|
||||
self.user.save()
|
||||
@@ -68,7 +71,10 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
|
||||
self.user_bad_passwd.password = UNUSABLE_PASSWORD_PREFIX
|
||||
self.user_bad_passwd.save()
|
||||
|
||||
@patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
|
||||
@patch(
|
||||
'openedx.core.djangoapps.user_authn.views.password_reset.render_to_string',
|
||||
Mock(side_effect=mock_render_to_string, autospec=True)
|
||||
)
|
||||
def test_user_bad_password_reset(self):
|
||||
"""
|
||||
Tests password reset behavior for user with password marked UNUSABLE_PASSWORD_PREFIX
|
||||
@@ -85,7 +91,10 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
|
||||
})
|
||||
self.assert_no_events_were_emitted()
|
||||
|
||||
@patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
|
||||
@patch(
|
||||
'openedx.core.djangoapps.user_authn.views.password_reset.render_to_string',
|
||||
Mock(side_effect=mock_render_to_string, autospec=True)
|
||||
)
|
||||
def test_nonexist_email_password_reset(self):
|
||||
"""
|
||||
Now test the exception cases with of reset_password called with invalid email.
|
||||
@@ -104,7 +113,10 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
|
||||
})
|
||||
self.assert_no_events_were_emitted()
|
||||
|
||||
@patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
|
||||
@patch(
|
||||
'openedx.core.djangoapps.user_authn.views.password_reset.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
|
||||
@@ -437,7 +449,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
|
||||
|
||||
self.assertEqual(response.context_data['err_msg'], password_dict['error_message'])
|
||||
|
||||
@patch('student.views.management.password_reset_confirm')
|
||||
@patch('openedx.core.djangoapps.user_authn.views.password_reset.password_reset_confirm')
|
||||
@patch("openedx.core.djangoapps.site_configuration.helpers.get_value", fake_get_value)
|
||||
def test_reset_password_good_token_configuration_override(self, reset_confirm):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user