Merge pull request #22233 from edx/diana/move-password-reset

Move password reset logic and code to user_authn.
This commit is contained in:
Diana Huang
2019-11-05 11:47:17 -05:00
committed by GitHub
18 changed files with 209 additions and 174 deletions

View File

@@ -28,45 +28,12 @@ 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 openedx.core.djangoapps.user_authn.views.password_reset import send_password_reset_email_for_user
from student.message_types import AccountRecovery as AccountRecoveryMessage
from student.message_types import PasswordReset
from student.models import AccountRecovery, CourseEnrollmentAllowed, email_exists_or_retired
from util.password_policy_validators import validate_password
def send_password_reset_email_for_user(user, request, preferred_email=None):
"""
Send out a password reset email for the given user.
Arguments:
user (User): Django User object
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.update({
'request': request, # Used by google_analytics_tracking_pixel
# TODO: This overrides `platform_name` from `get_base_template_context` to make the tests passes
'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
'reset_link': '{protocol}://{site}{link}?track=pwreset'.format(
protocol='https' if request.is_secure() else 'http',
site=configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME),
link=reverse('password_reset_confirm', kwargs={
'uidb36': int_to_base36(user.id),
'token': default_token_generator.make_token(user),
}),
)
})
msg = PasswordReset().personalize(
recipient=Recipient(user.username, preferred_email or user.email),
language=get_user_preference(user, LANGUAGE_KEY),
user_context=message_context,
)
ace.send(msg)
def send_account_recovery_email_for_user(user, request, email=None):
"""
Send out a account recovery email for the given user.

View File

@@ -7,13 +7,6 @@ from __future__ import absolute_import
from openedx.core.djangoapps.ace_common.message import BaseMessageType
class PasswordReset(BaseMessageType):
def __init__(self, *args, **kwargs):
super(PasswordReset, self).__init__(*args, **kwargs)
self.options['transactional'] = True
class AccountRecovery(BaseMessageType):
def __init__(self, *args, **kwargs):
super(AccountRecovery, self).__init__(*args, **kwargs)

View File

@@ -63,10 +63,11 @@ from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRI
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
from openedx.core.djangolib.markup import HTML, Text
from student.forms import AccountCreationForm, PasswordResetFormNoActive
from student.helpers import DISABLE_UNENROLL_CERT_STATES, cert_info, generate_activation_email_context
from student.message_types import EmailChange, EmailChangeConfirmation, PasswordReset, RecoveryEmailCreate
from student.message_types import EmailChange, EmailChangeConfirmation, RecoveryEmailCreate
from student.models import (
AccountRecovery,
CourseEnrollment,

View File

@@ -28,51 +28,6 @@ from util.password_policy_validators import (
)
def get_password_reset_form():
"""Return a description of the password reset form.
This decouples clients from the API definition:
if the API decides to modify the form, clients won't need
to be updated.
See `user_api.helpers.FormDescription` for examples
of the JSON-encoded form description.
Returns:
HttpResponse
"""
form_desc = FormDescription("post", reverse("password_change_request"))
# Translators: This label appears above a field on the password reset
# form meant to hold the user's email address.
email_label = _(u"Email")
# Translators: This example email address is used as a placeholder in
# a field on the password reset form meant to hold the user's email address.
email_placeholder = _(u"username@domain.com")
# Translators: These instructions appear on the password reset form,
# immediately below a field meant to hold the user's email address.
email_instructions = _(u"The email address you used to register with {platform_name}").format(
platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME)
)
form_desc.add_field(
"email",
field_type="email",
label=email_label,
placeholder=email_placeholder,
instructions=email_instructions,
restrictions={
"min_length": accounts.EMAIL_MIN_LENGTH,
"max_length": accounts.EMAIL_MAX_LENGTH,
}
)
return form_desc
def get_login_session_form(request):
"""Return a description of the login form.

View File

@@ -36,8 +36,3 @@ urlpatterns = [
user_api_views.CountryTimeZoneListView.as_view(),
),
]
urlpatterns += [
url(r'^v1/account/password_reset/$', user_api_views.PasswordResetView.as_view(),
name="user_api_password_reset"),
]

View File

@@ -564,65 +564,6 @@ class PreferenceUsersListViewTest(UserApiTestCase):
self.assertEqual(len(set(all_user_uris)), 2)
@ddt.ddt
@skip_unless_lms
class PasswordResetViewTest(UserAPITestCase):
"""Tests of the user API's password reset endpoint. """
def setUp(self):
super(PasswordResetViewTest, self).setUp()
self.url = reverse("user_api_password_reset")
@ddt.data("get", "post")
def test_auth_disabled(self, method):
self.assertAuthDisabled(method, self.url)
def test_allowed_methods(self):
self.assertAllowedMethods(self.url, ["GET", "HEAD", "OPTIONS"])
def test_put_not_allowed(self):
response = self.client.put(self.url)
self.assertHttpMethodNotAllowed(response)
def test_delete_not_allowed(self):
response = self.client.delete(self.url)
self.assertHttpMethodNotAllowed(response)
def test_patch_not_allowed(self):
response = self.client.patch(self.url)
self.assertHttpMethodNotAllowed(response)
def test_password_reset_form(self):
# Retrieve the password reset form
response = self.client.get(self.url, content_type="application/json")
self.assertHttpOK(response)
# Verify that the form description matches what we expect
form_desc = json.loads(response.content.decode('utf-8'))
self.assertEqual(form_desc["method"], "post")
self.assertEqual(form_desc["submit_url"], reverse("password_change_request"))
self.assertEqual(form_desc["fields"], [
{
"name": "email",
"defaultValue": "",
"type": "email",
"required": True,
"label": "Email",
"placeholder": "username@domain.com",
"instructions": u"The email address you used to register with {platform_name}".format(
platform_name=settings.PLATFORM_NAME
),
"restrictions": {
"min_length": EMAIL_MIN_LENGTH,
"max_length": EMAIL_MAX_LENGTH
},
"errorMessages": {},
"supplementalText": "",
"supplementalLink": "",
}
])
@ddt.ddt
@skip_unless_lms
class UpdateEmailOptInTestCase(UserAPITestCase, SharedModuleStoreTestCase):

View File

@@ -25,7 +25,6 @@ from openedx.core.djangoapps.user_api import accounts
from openedx.core.djangoapps.user_api.accounts.api import check_account_exists
from openedx.core.djangoapps.user_api.api import (
get_login_session_form,
get_password_reset_form
)
from openedx.core.lib.api.view_utils import require_post_params
from openedx.core.djangoapps.user_api.models import UserPreference
@@ -40,18 +39,6 @@ from student.helpers import AccountValidationError
from util.json_request import JsonResponse
class PasswordResetView(APIView):
"""HTTP end-point for GETting a description of the password reset form. """
# This end-point is available to anonymous users,
# so do not require authentication.
authentication_classes = []
@method_decorator(ensure_csrf_cookie)
def get(self, request):
return HttpResponse(get_password_reset_form().to_json(), content_type="application/json")
class UserViewSet(viewsets.ReadOnlyModelViewSet):
"""
DRF class for interacting with the User ORM object

View File

@@ -0,0 +1,15 @@
"""
ACE message types for user_authn-related emails.
"""
from __future__ import absolute_import
from openedx.core.djangoapps.ace_common.message import BaseMessageType
class PasswordReset(BaseMessageType):
def __init__(self, *args, **kwargs):
super(PasswordReset, self).__init__(*args, **kwargs)
# pylint: disable=unsupported-assignment-operation
self.options['transactional'] = True

View File

@@ -11,7 +11,7 @@ from __future__ import absolute_import
from django.conf import settings
from django.conf.urls import url
from .views import auto_auth, login, logout, register
from .views import auto_auth, login, logout, password_reset, register
urlpatterns = [
@@ -19,7 +19,6 @@ urlpatterns = [
url(r'^create_account$', register.RegistrationView.as_view(), name='create_account'),
url(r'^user_api/v1/account/registration/$', register.RegistrationView.as_view(),
name="user_api_registration"),
# Login
url(r'^login_post$', login.login_user, name='login_post'),
url(r'^login_ajax$', login.login_user, name="login"),
@@ -31,6 +30,10 @@ urlpatterns = [
url(r'^login_refresh$', login.login_refresh, name="login_refresh"),
url(r'^logout$', logout.LogoutView.as_view(), name='logout'),
url(r'^v1/account/password_reset/$', password_reset.PasswordResetView.as_view(),
name="user_api_password_reset"),
]

View File

@@ -32,9 +32,9 @@ from openedx.core.djangoapps.user_api.api import get_login_session_form
from openedx.core.djangoapps.user_authn.cookies import refresh_jwt_cookies, set_logged_in_cookies
from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
from openedx.core.djangoapps.user_authn.views.password_reset import send_password_reset_email_for_user
from openedx.core.djangolib.markup import HTML, Text
from openedx.core.lib.api.view_utils import require_post_params
from student.forms import send_password_reset_email_for_user
from student.models import LoginFailures
from student.views import send_reactivation_email_for_user
from third_party_auth import pipeline, provider

View File

@@ -20,10 +20,10 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_
from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled
from openedx.core.djangoapps.user_api.api import (
get_login_session_form,
get_password_reset_form
)
from openedx.core.djangoapps.user_authn.cookies import are_logged_in_cookies_set
from openedx.core.djangoapps.user_authn.views.registration_form import RegistrationFormFactory
from openedx.core.djangoapps.user_authn.views.password_reset import get_password_reset_form
from openedx.features.enterprise_support.api import enterprise_customer_for_request
from openedx.features.enterprise_support.utils import (
handle_enterprise_cookies_for_logistration,

View File

@@ -0,0 +1,114 @@
""" Handles password resets logic. """
from django.conf import settings
from django.contrib.auth.tokens import default_token_generator
from django.http import HttpResponse
from django.utils.decorators import method_decorator
from django.utils.http import int_to_base36
from django.utils.translation import ugettext_lazy as _
from django.urls import reverse
from django.views.decorators.csrf import ensure_csrf_cookie
from edx_ace import ace
from edx_ace.recipient import Recipient
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.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
from openedx.core.djangoapps.user_api.helpers import FormDescription
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
from openedx.core.djangoapps.user_authn.message_types import PasswordReset
def get_password_reset_form():
"""Return a description of the password reset form.
This decouples clients from the API definition:
if the API decides to modify the form, clients won't need
to be updated.
See `user_api.helpers.FormDescription` for examples
of the JSON-encoded form description.
Returns:
HttpResponse
"""
form_desc = FormDescription("post", reverse("password_change_request"))
# Translators: This label appears above a field on the password reset
# form meant to hold the user's email address.
email_label = _(u"Email")
# Translators: This example email address is used as a placeholder in
# a field on the password reset form meant to hold the user's email address.
email_placeholder = _(u"username@domain.com")
# Translators: These instructions appear on the password reset form,
# immediately below a field meant to hold the user's email address.
# pylint: disable=no-member
email_instructions = _(u"The email address you used to register with {platform_name}").format(
platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME)
)
form_desc.add_field(
"email",
field_type="email",
label=email_label,
placeholder=email_placeholder,
instructions=email_instructions,
restrictions={
"min_length": accounts.EMAIL_MIN_LENGTH,
"max_length": accounts.EMAIL_MAX_LENGTH,
}
)
return form_desc
class PasswordResetView(APIView):
"""HTTP end-point for GETting a description of the password reset form. """
# This end-point is available to anonymous users,
# so do not require authentication.
authentication_classes = []
@method_decorator(ensure_csrf_cookie)
def get(self, request):
return HttpResponse(get_password_reset_form().to_json(), content_type="application/json")
def send_password_reset_email_for_user(user, request, preferred_email=None):
"""
Send out a password reset email for the given user.
Arguments:
user (User): Django User object
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.update({
'request': request, # Used by google_analytics_tracking_pixel
# TODO: This overrides `platform_name` from `get_base_template_context` to make the tests passes
'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
'reset_link': '{protocol}://{site}{link}?track=pwreset'.format(
protocol='https' if request.is_secure() else 'http',
site=configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME),
link=reverse('password_reset_confirm', kwargs={
'uidb36': int_to_base36(user.id),
'token': default_token_generator.make_token(user),
}),
)
})
msg = PasswordReset().personalize(
recipient=Recipient(user.username, preferred_email or user.email),
language=get_user_preference(user, LANGUAGE_KEY),
user_context=message_context,
)
ace.send(msg)

View File

@@ -28,17 +28,19 @@ from six.moves import range
from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factories
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangolib.testing.utils import skip_unless_lms
from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, SYSTEM_MAINTENANCE_MSG, waffle
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.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
from .test_configuration_overrides import fake_get_value
@unittest.skipUnless(
settings.ROOT_URLCONF == "lms.urls",
@@ -53,7 +55,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
ENABLED_CACHES = ['default']
def setUp(self):
def setUp(self): # pylint: disable=arguments-differ
super(ResetPasswordTests, self).setUp('student.views.management.tracker')
self.user = UserFactory.create()
self.user.is_active = False
@@ -219,12 +221,12 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
password_reset(req)
_, msg, _, _ = send_email.call_args[0]
reset_msg = "you requested a password reset for your user account at {}"
reset_msg = u"you requested a password reset for your user account at {}"
reset_msg = reset_msg.format(site_name)
self.assertIn(reset_msg, msg)
sign_off = "The {} Team".format(platform_name)
sign_off = u"The {} Team".format(platform_name)
self.assertIn(sign_off, msg)
self.assert_event_emitted(
@@ -257,7 +259,9 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
body = bodies[body_type]
reset_msg = "you requested a password reset for your user account at {}".format(fake_get_value('PLATFORM_NAME'))
reset_msg = u"you requested a password reset for your user account at {}".format(
fake_get_value('PLATFORM_NAME')
)
self.assertIn(reset_msg, body)
@@ -378,6 +382,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
assert not self.user.is_active
def test_password_reset_normalize_password(self):
# pylint: disable=anomalous-unicode-escape-in-string
"""
Tests that if we provide a not properly normalized password, it is saved using our normalization
method of NFKC.
@@ -481,3 +486,62 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
reset_request.user = UserFactory.create()
self.assertRaises(Http404, password_reset_confirm_wrapper, reset_request, self.uidb36, self.token)
@ddt.ddt
@skip_unless_lms
class PasswordResetViewTest(UserAPITestCase):
"""Tests of the user API's password reset endpoint. """
def setUp(self):
super(PasswordResetViewTest, self).setUp()
self.url = reverse("user_api_password_reset")
@ddt.data("get", "post")
def test_auth_disabled(self, method):
self.assertAuthDisabled(method, self.url)
def test_allowed_methods(self):
self.assertAllowedMethods(self.url, ["GET", "HEAD", "OPTIONS"])
def test_put_not_allowed(self):
response = self.client.put(self.url)
self.assertHttpMethodNotAllowed(response)
def test_delete_not_allowed(self):
response = self.client.delete(self.url)
self.assertHttpMethodNotAllowed(response)
def test_patch_not_allowed(self):
response = self.client.patch(self.url)
self.assertHttpMethodNotAllowed(response)
def test_password_reset_form(self):
# Retrieve the password reset form
response = self.client.get(self.url, content_type="application/json")
self.assertHttpOK(response)
# Verify that the form description matches what we expect
form_desc = json.loads(response.content.decode('utf-8'))
self.assertEqual(form_desc["method"], "post")
self.assertEqual(form_desc["submit_url"], reverse("password_change_request"))
self.assertEqual(form_desc["fields"], [
{
"name": "email",
"defaultValue": "",
"type": "email",
"required": True,
"label": "Email",
"placeholder": "username@domain.com",
"instructions": u"The email address you used to register with {platform_name}".format(
platform_name=settings.PLATFORM_NAME
),
"restrictions": {
"min_length": EMAIL_MIN_LENGTH,
"max_length": EMAIL_MAX_LENGTH
},
"errorMessages": {},
"supplementalText": "",
"supplementalLink": "",
}
])