Skip to content
Snippets Groups Projects
Commit 6b1506c3 authored by Awais Jibran's avatar Awais Jibran
Browse files

Sends Post-password-change acknowledgement email

PROD-421
parent 67cddb7c
Branches
Tags release-2020-10-02-11.03
No related merge requests found
Showing with 143 additions and 54 deletions
......@@ -2,13 +2,25 @@
ACE message types for user_authn-related emails.
"""
from openedx.core.djangoapps.ace_common.message import BaseMessageType
class PasswordReset(BaseMessageType):
"""
A message to the user with password reset link.
"""
def __init__(self, *args, **kwargs):
super(PasswordReset, self).__init__(*args, **kwargs)
# pylint: disable=unsupported-assignment-operation
self.options['transactional'] = True
class PasswordResetSuccess(BaseMessageType):
"""
A message to the user when the password rest was successful.
"""
def __init__(self, *args, **kwargs):
super(PasswordResetSuccess, self).__init__(*args, **kwargs)
self.options['transactional'] = True
{% extends 'ace_common/edx_ace/common/base_body.html' %}
{% load i18n %}
{% load static %}
{% block content %}
<table width="100%" align="left" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
<h1>
{% trans "Password Reset Success" as tmsg %}{{ tmsg | force_escape }}
</h1>
<p style="color: rgba(0,0,0,.75);">
{% filter force_escape %}
{% blocktrans %}Hello {{ name }},{% endblocktrans %}
{% endfilter %}
</p>
<p style="color: rgba(0,0,0,.75);">
{% filter force_escape %}
{% blocktrans %}This is to confirm that you have successfully changed your password associated with {{ platform_name }} account. Please sign-in to your account.{% endblocktrans %}
{% endfilter %}
<br />
</p>
{% filter force_escape %}
{% blocktrans asvar course_cta_text %}Sign in{% endblocktrans %}
{% endfilter %}
{% include "ace_common/edx_ace/common/return_to_course_cta.html" with course_cta_text=course_cta_text course_cta_url=login_link %}
</td>
</tr>
</table>
{% endblock %}
{% load i18n %}{% autoescape off %}
{% blocktrans %}Hello {{ name }}, {% endblocktrans %}
{% blocktrans %}This is to confirm that you have successfully changed your password associated with {{ platform_name }} account. Please sign-in to your account.{% endblocktrans %}
{{ login_link }}
{% endautoescape %}
{% extends 'ace_common/edx_ace/common/base_head.html' %}
{% load i18n %}
{% autoescape off %}
{% blocktrans trimmed %}Password reset completed on {{ platform_name }}{% endblocktrans %}
{% endautoescape %}
......@@ -38,7 +38,7 @@ from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_f
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_authn.message_types import PasswordReset
from openedx.core.djangoapps.user_authn.message_types import PasswordReset, PasswordResetSuccess
from openedx.core.djangolib.markup import HTML
from student.forms import send_account_recovery_email_for_user
from student.models import AccountRecovery
......@@ -54,6 +54,16 @@ log = logging.getLogger("edx.student")
AUDIT_LOG = logging.getLogger("audit")
def get_user_default_email_params(user):
"""
Get default email params for the user.
"""
site = get_current_site()
message_context = get_base_template_context(site)
user_language_pref = get_user_preference(user, LANGUAGE_KEY)
return [message_context, user_language_pref]
def get_password_reset_form():
"""Return a description of the password reset form.
......@@ -100,6 +110,31 @@ def get_password_reset_form():
return form_desc
def send_password_reset_success_email(user, request):
"""
Send an email to user indicating that password reset was successful.
Arguments:
user (User): Django User object
request (HttpRequest): Django request object
"""
message_context, user_language_preference = get_user_default_email_params(user)
lms_root_url = configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL)
message_context.update(
{'login_link': '{}/login'.format(lms_root_url), 'request': request, }
)
msg = PasswordResetSuccess(context=message_context).personalize(
recipient=Recipient(user.username, user.email),
language=user_language_preference,
user_context={"name": user.profile.name},
)
try:
ace.send(msg)
except Exception: # pylint: disable=broad-except
log.exception('PasswordResetSuccess: sending email to user [%s] failed.', user.username)
def send_password_reset_email_for_user(user, request, preferred_email=None):
"""
Send out a password reset email for the given user.
......@@ -109,8 +144,7 @@ def send_password_reset_email_for_user(user, request, preferred_email=None):
request (HttpRequest): Django request object
preferred_email (str): Send email to this address if present, otherwise fallback to user's email address.
"""
site = get_current_site()
message_context = get_base_template_context(site)
message_context, user_language_preference = get_user_default_email_params(user)
message_context.update({
'request': request, # Used by google_analytics_tracking_pixel
# TODO: This overrides `platform_name` from `get_base_template_context` to make the tests passes
......@@ -127,7 +161,7 @@ def send_password_reset_email_for_user(user, request, preferred_email=None):
msg = PasswordReset().personalize(
recipient=Recipient(user.username, preferred_email or user.email),
language=get_user_preference(user, LANGUAGE_KEY),
language=user_language_preference,
user_context=message_context,
)
ace.send(msg)
......@@ -443,6 +477,7 @@ class PasswordResetConfirmWrapper(PasswordResetConfirmView):
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'])
is_account_recovery = 'is_account_recovery' in request.GET
password = request.POST['new_password1']
response = self._validate_password(password, request)
......@@ -455,17 +490,19 @@ class PasswordResetConfirmWrapper(PasswordResetConfirmView):
# 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:
password_reset_successful = response.status_code == 302
if not password_reset_successful:
return self._handle_password_reset_failure(response)
updated_user = User.objects.get(id=self.uid_int)
if 'is_account_recovery' in request.GET:
if is_account_recovery:
self._handle_primary_email_update(updated_user)
updated_user.save()
if response.status_code == 302 and 'is_account_recovery' in request.GET:
if password_reset_successful and is_account_recovery:
self._handle_password_creation(request, updated_user)
send_password_reset_success_email(updated_user, request)
return response
def dispatch(self, *args, **kwargs):
......
......@@ -2,7 +2,6 @@
Test the various password reset flows
"""
import json
import re
import unicodedata
......@@ -85,6 +84,21 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
process_request(request)
request.session[INTERNAL_RESET_SESSION_TOKEN] = self.token
@property
def password_reset_confirm_url(self):
"""
Returns Password reset confirm URL
"""
return reverse("password_reset_confirm", kwargs={"uidb36": self.uidb36, "token": self.token})
def send_password_reset_request(self):
"""
Sends GET request on password reset url.
"""
request = self.request_factory.get(self.password_reset_confirm_url)
self.setup_request_session_with_token(request)
return request
@patch(
'openedx.core.djangoapps.user_authn.views.password_reset.render_to_string',
Mock(side_effect=mock_render_to_string, autospec=True)
......@@ -170,6 +184,20 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
cache.clear()
def assert_email_sent_successfully(self, expected):
"""
Verify that the password confirm email has been sent to the user.
"""
from_email = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
sent_message = mail.outbox[0]
body = sent_message.body
self.assertIn(expected['subject'], sent_message.subject)
self.assertIn(expected['body'], body)
self.assertEqual(sent_message.from_email, from_email)
self.assertEqual(len(sent_message.to), 1)
self.assertIn(self.user.email, sent_message.to)
def test_ratelimitted_from_same_ip_with_different_email(self):
"""
Test that password reset endpoint allow only one request per minute per IP.
......@@ -405,12 +433,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
Then reset password page renders
And inactive user is set to active
"""
url = reverse(
"password_reset_confirm",
kwargs={"uidb36": self.uidb36, "token": self.token}
)
good_reset_req = self.request_factory.get(url)
process_request(good_reset_req)
good_reset_req = self.send_password_reset_request()
good_reset_req.user = self.user
redirect_response = PasswordResetConfirmWrapper.as_view()(good_reset_req, uidb36=self.uidb36, token=self.token)
......@@ -435,12 +458,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
Then reset password page renders
And inactive user associated with token is set to active
"""
url = reverse(
"password_reset_confirm",
kwargs={"uidb36": self.uidb36, "token": self.token}
)
good_reset_req = self.request_factory.get(url)
process_request(good_reset_req)
good_reset_req = self.send_password_reset_request()
good_reset_req.user = AnonymousUser()
redirect_response = PasswordResetConfirmWrapper.as_view()(good_reset_req, uidb36=self.uidb36, token=self.token)
......@@ -459,12 +477,8 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
"""
self.assertFalse(self.user.is_active)
url = reverse(
'password_reset_confirm',
kwargs={'uidb36': self.uidb36, 'token': self.token}
)
request_params = {'new_password1': 'password1', 'new_password2': 'password2'}
confirm_request = self.request_factory.post(url, data=request_params)
confirm_request = self.request_factory.post(self.password_reset_confirm_url, data=request_params)
self.setup_request_session_with_token(confirm_request)
confirm_request.user = self.user
......@@ -485,11 +499,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
# Retire the user.
UserRetirementRequest.create_retirement_request(self.user)
url = reverse(
'password_reset_confirm',
kwargs={'uidb36': self.uidb36, 'token': self.token}
)
reset_req = self.request_factory.get(url)
reset_req = self.request_factory.get(self.password_reset_confirm_url)
reset_req.user = self.user
resp = PasswordResetConfirmWrapper.as_view()(reset_req, uidb36=self.uidb36, token=self.token)
......@@ -505,17 +515,13 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
method of NFKC.
In this test, the input password is u'p\u212bssword'. It should be normalized to u'p\xc5ssword'
"""
url = reverse(
"password_reset_confirm",
kwargs={"uidb36": self.uidb36, "token": self.token}
)
password = u'p\u212bssword'
request_params = {'new_password1': password, 'new_password2': password}
confirm_request = self.request_factory.post(url, data=request_params)
confirm_request = self.request_factory.post(self.password_reset_confirm_url, data=request_params)
process_request(confirm_request)
confirm_request.session[INTERNAL_RESET_SESSION_TOKEN] = self.token
confirm_request.user = self.user
confirm_request.site = Mock(domain='example.com')
__ = PasswordResetConfirmWrapper.as_view()(confirm_request, uidb36=self.uidb36, token=self.token)
user = User.objects.get(pk=self.user.pk)
......@@ -523,6 +529,11 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
expected_user_password = make_password(unicodedata.normalize('NFKC', u'p\u212bssword'), salt_val)
self.assertEqual(expected_user_password, user.password)
self.assert_email_sent_successfully({
'subject': 'Password reset completed',
'body': 'This is to confirm that you have successfully changed your password'
})
@override_settings(AUTH_PASSWORD_VALIDATORS=[
create_validator_config('util.password_policy_validators.MinimumLengthValidator', {'min_length': 2}),
create_validator_config('util.password_policy_validators.MaximumLengthValidator', {'max_length': 10})
......@@ -542,13 +553,8 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
Tests that if we provide password characters less then PASSWORD_MIN_LENGTH,
or more than PASSWORD_MAX_LENGTH, password reset will fail with error message.
"""
url = reverse(
'password_reset_confirm',
kwargs={'uidb36': self.uidb36, 'token': self.token}
)
request_params = {'new_password1': password_dict['password'], 'new_password2': password_dict['password']}
confirm_request = self.request_factory.post(url, data=request_params)
confirm_request = self.request_factory.post(self.password_reset_confirm_url, data=request_params)
self.setup_request_session_with_token(confirm_request)
confirm_request.user = self.user
......@@ -563,12 +569,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
"""
Tests password reset confirmation page for site configuration override.
"""
url = reverse(
"password_reset_confirm",
kwargs={"uidb36": self.uidb36, "token": self.token}
)
good_reset_req = self.request_factory.get(url)
process_request(good_reset_req)
good_reset_req = self.send_password_reset_request()
good_reset_req.user = self.user
PasswordResetConfirmWrapper.as_view()(good_reset_req, uidb36=self.uidb36, token=self.token)
confirm_kwargs = reset_confirm.call_args[1]
......@@ -599,11 +600,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
"""
Tests that user should not be able to reset password through other user's token
"""
reset_url = reverse(
"password_reset_confirm",
kwargs={"uidb36": self.uidb36, "token": self.token}
)
reset_request = self.request_factory.get(reset_url)
reset_request = self.request_factory.get(self.password_reset_confirm_url)
reset_request.user = UserFactory.create()
self.assertRaises(Http404, PasswordResetConfirmWrapper.as_view(), reset_request, uidb36=self.uidb36,
......
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