Consolidate recovery assistance forms

This commit is contained in:
Saleem Latif
2019-01-16 16:26:13 +05:00
parent 33fab4b1e4
commit 46d97caa47
18 changed files with 69 additions and 709 deletions

View File

@@ -21,10 +21,10 @@ from edx_ace import ace
from edx_ace.recipient import Recipient
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.djangolib.markup import HTML, Text
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.theming.helpers import get_current_site
from openedx.core.djangoapps.user_api import accounts as accounts_settings
from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
from student.message_types import AccountRecovery as AccountRecoveryMessage, PasswordReset
from student.models import AccountRecovery, CourseEnrollmentAllowed, email_exists_or_retired
@@ -78,10 +78,10 @@ def send_account_recovery_email_for_user(user, request, email=None):
message_context.update({
'request': request, # Used by google_analytics_tracking_pixel
'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
'reset_link': '{protocol}://{site}{link}'.format(
'reset_link': '{protocol}://{site}{link}?is_account_recovery=true'.format(
protocol='https' if request.is_secure() else 'http',
site=configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME),
link=reverse('account_recovery_confirm', kwargs={
link=reverse('password_reset_confirm', kwargs={
'uidb36': int_to_base36(user.id),
'token': default_token_generator.make_token(user),
}),
@@ -104,6 +104,8 @@ class PasswordResetFormNoActive(PasswordResetForm):
"address cannot reset the password."),
}
is_account_recovery = True
def clean_email(self):
"""
This is a literal copy from Django 1.4.5's django.contrib.auth.forms.PasswordResetForm
@@ -113,6 +115,14 @@ class PasswordResetFormNoActive(PasswordResetForm):
email = self.cleaned_data["email"]
#The line below contains the only change, removing is_active=True
self.users_cache = User.objects.filter(email__iexact=email)
if len(self.users_cache) == 0 and is_secondary_email_feature_enabled():
# Check if user has entered the secondary email.
self.users_cache = User.objects.filter(
id__in=AccountRecovery.objects.filter(secondary_email__iexact=email, is_active=True).values_list('user')
)
self.is_account_recovery = not bool(self.users_cache)
if not len(self.users_cache):
raise forms.ValidationError(self.error_messages['unknown'])
if any((user.password.startswith(UNUSABLE_PASSWORD_PREFIX))
@@ -130,57 +140,10 @@ class PasswordResetFormNoActive(PasswordResetForm):
user.
"""
for user in self.users_cache:
send_password_reset_email_for_user(user, request)
class AccountRecoveryForm(PasswordResetFormNoActive):
error_messages = {
'unknown': _(
HTML(
'That secondary e-mail address doesn\'t have an associated user account. Are you sure you had added '
'a verified secondary email address for account recovery in your account settings? Please '
'<a href={support_url}">contact support</a> for further assistance.'
)
).format(
support_url=configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK),
),
'unusable': _(
Text(
'The user account associated with this secondary e-mail address cannot reset the password.'
)
),
}
def clean_email(self):
"""
This is a literal copy from Django's django.contrib.auth.forms.PasswordResetForm
Except removing the requirement of active users
Validates that a user exists with the given secondary email.
"""
email = self.cleaned_data["email"]
# The line below contains the only change, getting users via AccountRecovery
self.users_cache = User.objects.filter(
id__in=AccountRecovery.objects.filter(secondary_email__iexact=email, is_active=True).values_list('user')
)
if not len(self.users_cache):
raise forms.ValidationError(self.error_messages['unknown'])
if any((user.password.startswith(UNUSABLE_PASSWORD_PREFIX))
for user in self.users_cache):
raise forms.ValidationError(self.error_messages['unusable'])
return email
def save(self, # pylint: disable=arguments-differ
use_https=False,
token_generator=default_token_generator,
request=None,
**_kwargs):
"""
Generates a one-use only link for setting the password and sends to the
user.
"""
for user in self.users_cache:
send_account_recovery_email_for_user(user, request, user.account_recovery.secondary_email)
if self.is_account_recovery:
send_password_reset_email_for_user(user, request)
else:
send_account_recovery_email_for_user(user, request, user.account_recovery.secondary_email)
class TrueCheckbox(widgets.CheckboxInput):

View File

@@ -22,18 +22,12 @@ urlpatterns = [
# 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'^account/account_recovery', views.account_recovery_request_handler, name='account_recovery'),
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'^account_recovery_confirm/(?P<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/$',
views.account_recovery_confirm_wrapper,
name='account_recovery_confirm',
),
url(r'^course_run/{}/refund_status$'.format(settings.COURSE_ID_PATTERN),
views.course_run_refund_status,

View File

@@ -15,6 +15,7 @@ from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.auth.views import password_reset_confirm
from django.contrib.sites.models import Site
from django.core.exceptions import ObjectDoesNotExist
from django.core import mail
from django.urls import reverse
from django.core.validators import ValidationError, validate_email
@@ -681,7 +682,7 @@ def password_change_request_handler(request):
try:
from openedx.core.djangoapps.user_api.accounts.api import request_password_change
request_password_change(email, request.is_secure())
user = user if user.is_authenticated else User.objects.get(email=email)
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")
@@ -719,58 +720,26 @@ def password_change_request_handler(request):
return HttpResponseBadRequest(_("No email address provided."))
@require_http_methods(['POST'])
def account_recovery_request_handler(request):
def get_user_from_email(email):
"""
Handle account recovery requests.
Find a user using given email and return it.
Arguments:
request (HttpRequest)
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:
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
HttpResponse: 404 if account recovery feature is not enabled
Example:
POST /account/account_recovery
User: Django user object.
"""
if not is_secondary_email_feature_enabled():
raise Http404
limiter = BadRequestRateLimiter()
if limiter.is_rate_limit_exceeded(request):
AUDIT_LOG.warning("Account recovery rate limit exceeded")
return HttpResponseForbidden()
user = request.user
# Prefer logged-in user's email
email = request.POST.get('email')
if email:
try:
# Send an email with a link to direct user towards account recovery.
from openedx.core.djangoapps.user_api.accounts.api import request_account_recovery
request_account_recovery(email, request.is_secure())
# Check if a user exists with the given secondary email, if so then invalidate the existing oauth tokens.
user = user if user.is_authenticated else User.objects.get(
id=AccountRecovery.objects.get_active(secondary_email__iexact=email).user.id
)
destroy_oauth_tokens(user)
except UserNotFound:
AUDIT_LOG.warning(
"Account recovery attempt via invalid secondary email '{email}'.".format(email=email)
)
limiter.tick_bad_request_counter(request)
return HttpResponse(status=200)
else:
return HttpResponseBadRequest(_("No email address provided."))
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
@@ -841,6 +810,11 @@ def password_reset_confirm_wrapper(request, uidb36=None, token=None):
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)
user = User.objects.get(id=uid_int)
@@ -909,9 +883,19 @@ def password_reset_confirm_wrapper(request, uidb36=None, token=None):
# remember what the old password hash is before we call down
old_password_hash = user.password
response = password_reset_confirm(
request, uidb64=uidb64, token=token, extra_context=platform_name
)
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.
@@ -930,113 +914,20 @@ def password_reset_confirm_wrapper(request, uidb36=None, token=None):
# get the updated user
updated_user = User.objects.get(id=uid_int)
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 account_recovery_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 secondary email feature is enabled.
if not is_secondary_email_feature_enabled():
raise Http404
try:
uid_int = base36_to_int(uidb36)
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,
template_name='account_recovery/password_create_confirm.html'
)
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 creation unsuccessful'),
'err_msg': ' '.join(err.messages),
}
context.update(platform_name)
return TemplateResponse(
request, 'account_recovery/password_create_confirm.html', context
)
# remember what the old password hash is before we call down
old_password_hash = user.password
response = password_reset_confirm(
request,
uidb64=uidb64,
token=token,
extra_context=platform_name,
template_name='account_recovery/password_create_confirm.html',
post_reset_redirect='signin_user',
)
# 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 create password for user [%s] because form is not valid. '
u'A possible cause is that the user had an invalid create token',
user.username,
if 'is_account_recovery' in request.GET:
try:
updated_user.email = updated_user.account_recovery.secondary_email
updated_user.account_recovery.delete()
except ObjectDoesNotExist:
log.error(
'Account recovery process initiated without AccountRecovery instance for user {username}'.format(
username=updated_user.username
)
)
response.context_data['err_msg'] = _('Error in creating your password. Please try again.')
return response
# get the updated user
updated_user = User.objects.get(id=uid_int)
updated_user.email = updated_user.account_recovery.secondary_email
updated_user.save()
if response.status_code == 302:
if response.status_code == 302 and 'is_account_recovery' in request.GET:
messages.success(
request,
HTML(_(
@@ -1054,11 +945,7 @@ def account_recovery_confirm_wrapper(request, uidb36=None, token=None):
)
else:
response = password_reset_confirm(
request,
uidb64=uidb64,
token=token,
extra_context=platform_name,
template_name='account_recovery/password_create_confirm.html',
request, uidb64=uidb64, token=token, extra_context=platform_name
)
response_was_successful = response.context_data.get('validlink')