diff --git a/cms/envs/test.py b/cms/envs/test.py index f9ace115b14b..10451b815107 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -32,6 +32,7 @@ COMPREHENSIVE_THEME_DIRS, JWT_AUTH, REGISTRATION_EXTRA_FIELDS, + ECOMMERCE_API_URL, ) # Allow all hosts during tests, we use a lot of different ones all over the codebase. diff --git a/cms/urls.py b/cms/urls.py index 4883d92f75ef..0694b48a836b 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -33,6 +33,7 @@ LIBRARY_KEY_PATTERN = r'(?Plibrary-v1:[^/+]+\+[^/+]+)' urlpatterns = [ + url(r'', include('openedx.core.djangoapps.user_authn.urls_common')), url(r'', include('student.urls')), url(r'^transcripts/upload$', contentstore.views.upload_transcripts, name='upload_transcripts'), url(r'^transcripts/download$', contentstore.views.download_transcripts, name='download_transcripts'), diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index 709009f06b78..0a7e62898bbb 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -17,10 +17,6 @@ from django.db import IntegrityError, transaction from django.utils import http from django.utils.translation import ugettext as _ -from oauth2_provider.models import AccessToken as dot_access_token -from oauth2_provider.models import RefreshToken as dot_refresh_token -from provider.oauth2.models import AccessToken as dop_access_token -from provider.oauth2.models import RefreshToken as dop_refresh_token from pytz import UTC from six import iteritems, text_type import third_party_auth @@ -372,16 +368,6 @@ def get_redirect_to(request): return redirect_to -def destroy_oauth_tokens(user): - """ - Destroys ALL OAuth access and refresh tokens for the given user. - """ - dop_access_token.objects.filter(user=user.id).delete() - dop_refresh_token.objects.filter(user=user.id).delete() - dot_access_token.objects.filter(user=user.id).delete() - dot_refresh_token.objects.filter(user=user.id).delete() - - def generate_activation_email_context(user, registration): """ Constructs a dictionary for use in activation email contexts diff --git a/common/djangoapps/student/management/commands/create_user.py b/common/djangoapps/student/management/commands/create_user.py index 3ea24061465d..4d43c497350f 100644 --- a/common/djangoapps/student/management/commands/create_user.py +++ b/common/djangoapps/student/management/commands/create_user.py @@ -10,7 +10,7 @@ from student.forms import AccountCreationForm from student.helpers import do_create_account from student.models import CourseEnrollment, create_comments_service_user -from student.views import AccountValidationError +from student.helpers import AccountValidationError from track.management.tracked_command import TrackedCommand diff --git a/common/djangoapps/student/tests/test_email.py b/common/djangoapps/student/tests/test_email.py index 5146c5ce2fc0..6f502997b981 100644 --- a/common/djangoapps/student/tests/test_email.py +++ b/common/djangoapps/student/tests/test_email.py @@ -196,7 +196,7 @@ def test_send_email_to_inactive_user(self, mock_log): ) -@patch('student.views.login.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) +@patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) @patch('django.contrib.auth.models.User.email_user') class ReactivationEmailTests(EmailTestMixin, CacheIsolationTestCase): """ diff --git a/common/djangoapps/student/tests/test_password_policy.py b/common/djangoapps/student/tests/test_password_policy.py index 00e67648cb78..1b8dc1ce048d 100644 --- a/common/djangoapps/student/tests/test_password_policy.py +++ b/common/djangoapps/student/tests/test_password_policy.py @@ -15,7 +15,7 @@ from openedx.core.djangoapps.external_auth.models import ExternalAuthMap from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory -from student.views import create_account +from openedx.core.djangoapps.user_authn.views.deprecated import create_account @patch.dict("django.conf.settings.FEATURES", {'ENFORCE_PASSWORD_POLICY': True}) diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py index ffa5c6b08902..dc6e5a27f3fe 100644 --- a/common/djangoapps/student/tests/test_views.py +++ b/common/djangoapps/student/tests/test_views.py @@ -29,7 +29,7 @@ from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context from pyquery import PyQuery as pq -from student.cookies import get_user_info_cookie_data +from openedx.core.djangoapps.user_authn.cookies import get_user_info_cookie_data from student.helpers import DISABLE_UNENROLL_CERT_STATES from student.models import CourseEnrollment, UserProfile from student.signals import REFUND_ORDER diff --git a/common/djangoapps/student/urls.py b/common/djangoapps/student/urls.py index c1eeaad7a765..360d6a23c7f5 100644 --- a/common/djangoapps/student/urls.py +++ b/common/djangoapps/student/urls.py @@ -9,19 +9,9 @@ from . import views urlpatterns = [ - url(r'^logout$', views.LogoutView.as_view(), name='logout'), - - # TODO: standardize login - - # login endpoint used by cms. - url(r'^login_post$', views.login_user, name='login_post'), - # login endpoints used by lms. - url(r'^login_ajax$', views.login_user, name="login"), - url(r'^login_ajax/(?P[^/]*)$', views.login_user), url(r'^email_confirm/(?P[^/]*)$', views.confirm_email_change, name='confirm_email_change'), - url(r'^create_account$', views.create_account, name='create_account'), url(r'^activate/(?P[^/]*)$', views.activate_account, name="activate"), url(r'^accounts/disable_account_ajax$', views.disable_account_ajax, name="disable_account_ajax"), @@ -31,6 +21,7 @@ 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[0-9A-Za-z]+)-(?P.+)/$', @@ -38,19 +29,11 @@ name='password_reset_confirm', ), - url(r'accounts/verify_password', views.verify_user_password, name='verify_password'), - url(r'^course_run/{}/refund_status$'.format(settings.COURSE_ID_PATTERN), views.course_run_refund_status, name="course_run_refund_status"), ] -# enable automatic login -if settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING'): - urlpatterns += [ - url(r'^auto_auth$', views.auto_auth), - ] - # password reset django views (see above for password reset views) urlpatterns += [ # TODO: Replace with Mako-ized views diff --git a/common/djangoapps/student/views/__init__.py b/common/djangoapps/student/views/__init__.py index eefb35ad176b..5e68545faa67 100644 --- a/common/djangoapps/student/views/__init__.py +++ b/common/djangoapps/student/views/__init__.py @@ -4,5 +4,4 @@ # pylint: disable=wildcard-import from dashboard import * -from login import * from management import * diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py index 441417804023..c4610795b1e6 100644 --- a/common/djangoapps/student/views/dashboard.py +++ b/common/djangoapps/student/views/dashboard.py @@ -44,7 +44,7 @@ from openedx.features.journals.api import journals_enabled from shoppingcart.api import order_history from shoppingcart.models import CourseRegistrationCode, DonationConfiguration -from student.cookies import set_user_info_cookie +from openedx.core.djangoapps.user_authn.cookies import set_user_info_cookie from student.helpers import cert_info, check_verify_status_by_course from student.models import ( CourseEnrollment, diff --git a/common/djangoapps/student/views/login.py b/common/djangoapps/student/views/login.py deleted file mode 100644 index aea20140be11..000000000000 --- a/common/djangoapps/student/views/login.py +++ /dev/null @@ -1,810 +0,0 @@ -""" -Views for login / logout and associated functionality - -Much of this file was broken out from views.py, previous history can be found there. -""" - -import datetime -import logging -import uuid -import warnings -from urlparse import parse_qs, urlsplit, urlunsplit - -import analytics -import edx_oauth2_provider -from django.conf import settings -from django.contrib import messages -from django.contrib.auth import authenticate, load_backend, login as django_login, logout -from django.contrib.auth.decorators import login_required -from django.contrib.auth.models import AnonymousUser, User -from django.core.exceptions import ObjectDoesNotExist, PermissionDenied -from django.urls import NoReverseMatch, reverse, reverse_lazy -from django.core.validators import ValidationError, validate_email -from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden -from django.shortcuts import redirect -from django.template.context_processors import csrf -from django.utils.http import base36_to_int, is_safe_url, urlencode, urlsafe_base64_encode -from django.utils.translation import ugettext as _ -from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie -from django.views.decorators.http import require_GET, require_POST -from django.views.generic import TemplateView -from opaque_keys.edx.locator import CourseLocator -from provider.oauth2.models import Client -from ratelimitbackend.exceptions import RateLimitException -from requests import HTTPError -from six import text_type -from social_core.backends import oauth as social_oauth -from social_core.exceptions import AuthAlreadyAssociated, AuthException -from social_django import utils as social_utils - -import openedx.core.djangoapps.external_auth.views -import third_party_auth -from django_comment_common.models import assign_role -from edxmako.shortcuts import render_to_response, render_to_string -from eventtracking import tracker -from openedx.core.djangoapps.external_auth.login_and_register import login as external_auth_login -from openedx.core.djangoapps.external_auth.models import ExternalAuthMap -from openedx.core.djangoapps.password_policy import compliance as password_policy_compliance -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.core.djangoapps.user_api.accounts.utils import generate_password -from openedx.core.djangoapps.util.user_messages import PageLevelMessages -from openedx.features.course_experience import course_home_url_name -from student.cookies import delete_logged_in_cookies, set_logged_in_cookies -from student.forms import AccountCreationForm -from student.helpers import ( - AccountValidationError, - auth_pipeline_urls, - create_or_set_user_attribute_created_on_site, - generate_activation_email_context, - get_next_url_for_login_page -) -from student.models import ( - CourseAccessRole, - CourseEnrollment, - LoginFailures, - PasswordHistory, - Registration, - UserProfile, - anonymous_id_for_user, - create_comments_service_user -) -from student.helpers import authenticate_new_user, do_create_account -from third_party_auth import pipeline, provider -from util.json_request import JsonResponse - -log = logging.getLogger("edx.student") -AUDIT_LOG = logging.getLogger("audit") - - -class AuthFailedError(Exception): - """ - This is a helper for the login view, allowing the various sub-methods to early out with an appropriate failure - message. - """ - def __init__(self, value=None, redirect=None, redirect_url=None): - self.value = value - self.redirect = redirect - self.redirect_url = redirect_url - - def get_response(self): - resp = {'success': False} - for attr in ('value', 'redirect', 'redirect_url'): - if self.__getattribute__(attr) and len(self.__getattribute__(attr)): - resp[attr] = self.__getattribute__(attr) - - return resp - - -def _do_third_party_auth(request): - """ - User is already authenticated via 3rd party, now try to find and return their associated Django user. - """ - running_pipeline = pipeline.get(request) - username = running_pipeline['kwargs'].get('username') - backend_name = running_pipeline['backend'] - third_party_uid = running_pipeline['kwargs']['uid'] - requested_provider = provider.Registry.get_from_pipeline(running_pipeline) - platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME) - - try: - return pipeline.get_authenticated_user(requested_provider, username, third_party_uid) - except User.DoesNotExist: - AUDIT_LOG.info( - u"Login failed - user with username {username} has no social auth " - "with backend_name {backend_name}".format( - username=username, backend_name=backend_name) - ) - message = _( - "You've successfully logged into your {provider_name} account, " - "but this account isn't linked with an {platform_name} account yet." - ).format( - platform_name=platform_name, - provider_name=requested_provider.name, - ) - message += "

" - message += _( - "Use your {platform_name} username and password to log into {platform_name} below, " - "and then link your {platform_name} account with {provider_name} from your dashboard." - ).format( - platform_name=platform_name, - provider_name=requested_provider.name, - ) - message += "

" - message += _( - "If you don't have an {platform_name} account yet, " - "click Register at the top of the page." - ).format( - platform_name=platform_name - ) - - raise AuthFailedError(message) - - -def _get_user_by_email(request): - """ - Finds a user object in the database based on the given request, ignores all fields except for email. - """ - if 'email' not in request.POST or 'password' not in request.POST: - raise AuthFailedError(_('There was an error receiving your login information. Please email us.')) - - email = request.POST['email'] - - try: - return User.objects.get(email=email) - except User.DoesNotExist: - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - AUDIT_LOG.warning(u"Login failed - Unknown user email") - else: - AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email)) - - -def _check_shib_redirect(user): - """ - See if the user has a linked shibboleth account, if so, redirect the user to shib-login. - This behavior is pretty much like what gmail does for shibboleth. Try entering some @stanford.edu - address into the Gmail login. - """ - if settings.FEATURES.get('AUTH_USE_SHIB') and user: - try: - eamap = ExternalAuthMap.objects.get(user=user) - if eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX): - raise AuthFailedError('', redirect=reverse('shib-login')) - except ExternalAuthMap.DoesNotExist: - # This is actually the common case, logging in user without external linked login - AUDIT_LOG.info(u"User %s w/o external auth attempting login", user) - - -def _check_excessive_login_attempts(user): - """ - See if account has been locked out due to excessive login failures - """ - if user and LoginFailures.is_feature_enabled(): - if LoginFailures.is_user_locked_out(user): - raise AuthFailedError(_('This account has been temporarily locked due ' - 'to excessive login failures. Try again later.')) - - -def _check_forced_password_reset(user): - """ - See if the user must reset his/her password due to any policy settings - """ - if user and PasswordHistory.should_user_reset_password_now(user): - raise AuthFailedError(_('Your password has expired due to password policy on this account. You must ' - 'reset your password before you can log in again. Please click the ' - '"Forgot Password" link on this page to reset your password before logging in again.')) - - -def _enforce_password_policy_compliance(request, user): - try: - password_policy_compliance.enforce_compliance_on_login(user, request.POST.get('password')) - except password_policy_compliance.NonCompliantPasswordWarning as e: - # Allow login, but warn the user that they will be required to reset their password soon. - PageLevelMessages.register_warning_message(request, e.message) - except password_policy_compliance.NonCompliantPasswordException as e: - # Prevent the login attempt. - raise AuthFailedError(e.message) - - -def _generate_not_activated_message(user): - """ - Generates the message displayed on the sign-in screen when a learner attempts to access the - system with an inactive account. - """ - - support_url = configuration_helpers.get_value( - 'SUPPORT_SITE_LINK', - settings.SUPPORT_SITE_LINK - ) - - platform_name = configuration_helpers.get_value( - 'PLATFORM_NAME', - settings.PLATFORM_NAME - ) - - not_activated_msg_template = _('In order to sign in, you need to activate your account.

' - 'We just sent an activation link to {email}. If ' - 'you do not receive an email, check your spam folders or ' - 'contact {platform} Support.') - - not_activated_message = not_activated_msg_template.format( - email=user.email, - support_url=support_url, - platform=platform_name - ) - - return not_activated_message - - -def _log_and_raise_inactive_user_auth_error(unauthenticated_user): - """ - Depending on Django version we can get here a couple of ways, but this takes care of logging an auth attempt - by an inactive user, re-sending the activation email, and raising an error with the correct message. - """ - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - AUDIT_LOG.warning( - u"Login failed - Account not active for user.id: {0}, resending activation".format( - unauthenticated_user.id) - ) - else: - AUDIT_LOG.warning(u"Login failed - Account not active for user {0}, resending activation".format( - unauthenticated_user.username) - ) - - send_reactivation_email_for_user(unauthenticated_user) - raise AuthFailedError(_generate_not_activated_message(unauthenticated_user)) - - -def _authenticate_first_party(request, unauthenticated_user): - """ - Use Django authentication on the given request, using rate limiting if configured - """ - - # If the user doesn't exist, we want to set the username to an invalid username so that authentication is guaranteed - # to fail and we can take advantage of the ratelimited backend - username = unauthenticated_user.username if unauthenticated_user else "" - - try: - return authenticate( - username=username, - password=request.POST['password'], - request=request) - - # This occurs when there are too many attempts from the same IP address - except RateLimitException: - raise AuthFailedError(_('Too many failed login attempts. Try again later.')) - - -def _handle_failed_authentication(user): - """ - Handles updating the failed login count, inactive user notifications, and logging failed authentications. - """ - if user: - if LoginFailures.is_feature_enabled(): - LoginFailures.increment_lockout_counter(user) - - if not user.is_active: - _log_and_raise_inactive_user_auth_error(user) - - # if we didn't find this username earlier, the account for this email - # doesn't exist, and doesn't have a corresponding password - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - loggable_id = user.id if user else "" - AUDIT_LOG.warning(u"Login failed - password for user.id: {0} is invalid".format(loggable_id)) - else: - AUDIT_LOG.warning(u"Login failed - password for {0} is invalid".format(user.email)) - - raise AuthFailedError(_('Email or password is incorrect.')) - - -def _handle_successful_authentication_and_login(user, request): - """ - Handles clearing the failed login counter, login tracking, and setting session timeout. - """ - if LoginFailures.is_feature_enabled(): - LoginFailures.clear_lockout_counter(user) - - _track_user_login(user, request) - - try: - django_login(request, user) - if request.POST.get('remember') == 'true': - request.session.set_expiry(604800) - log.debug("Setting user session to never expire") - else: - request.session.set_expiry(0) - except Exception as exc: # pylint: disable=broad-except - AUDIT_LOG.critical("Login failed - Could not create session. Is memcached running?") - log.critical("Login failed - Could not create session. Is memcached running?") - log.exception(exc) - raise - - -def _track_user_login(user, request): - """ - Sends a tracking event for a successful login. - """ - if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: - tracking_context = tracker.get_tracker().resolve_context() - analytics.identify( - user.id, - { - 'email': request.POST['email'], - 'username': user.username - }, - { - # Disable MailChimp because we don't want to update the user's email - # and username in MailChimp on every page load. We only need to capture - # this data on registration/activation. - 'MailChimp': False - } - ) - - analytics.track( - user.id, - "edx.bi.user.account.authenticated", - { - 'category': "conversion", - 'label': request.POST.get('course_id'), - 'provider': None - }, - context={ - 'ip': tracking_context.get('ip'), - 'Google Analytics': { - 'clientId': tracking_context.get('client_id') - } - } - ) - - -def send_reactivation_email_for_user(user): - try: - registration = Registration.objects.get(user=user) - except Registration.DoesNotExist: - return JsonResponse({ - "success": False, - "error": _('No inactive user with this e-mail exists'), - }) - - try: - context = generate_activation_email_context(user, registration) - except ObjectDoesNotExist: - log.error( - u'Unable to send reactivation email due to unavailable profile for the user "%s"', - user.username, - exc_info=True - ) - return JsonResponse({ - "success": False, - "error": _('Unable to send reactivation email') - }) - - subject = render_to_string('emails/activation_email_subject.txt', context) - subject = ''.join(subject.splitlines()) - message = render_to_string('emails/activation_email.txt', context) - from_address = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) - from_address = configuration_helpers.get_value('ACTIVATION_EMAIL_FROM_ADDRESS', from_address) - - try: - user.email_user(subject, message, from_address) - except Exception: # pylint: disable=broad-except - log.error( - u'Unable to send reactivation email from "%s" to "%s"', - from_address, - user.email, - exc_info=True - ) - return JsonResponse({ - "success": False, - "error": _('Unable to send reactivation email') - }) - - return JsonResponse({"success": True}) - - -@login_required -@ensure_csrf_cookie -def verify_user_password(request): - """ - If the user is logged in and we want to verify that they have submitted the correct password - for a major account change (for example, retiring this user's account). - - Args: - request (HttpRequest): A request object where the password should be included in the POST fields. - """ - try: - _check_excessive_login_attempts(request.user) - user = authenticate(username=request.user.username, password=request.POST['password'], request=request) - if user: - if LoginFailures.is_feature_enabled(): - LoginFailures.clear_lockout_counter(user) - return JsonResponse({'success': True}) - else: - _handle_failed_authentication(request.user) - except AuthFailedError as err: - return HttpResponse(err.value, content_type="text/plain", status=403) - except Exception: # pylint: disable=broad-except - log.exception("Could not verify user password") - return HttpResponseBadRequest() - - -@ensure_csrf_cookie -def login_user(request): - """ - AJAX request to log in the user. - """ - third_party_auth_requested = third_party_auth.is_enabled() and pipeline.running(request) - trumped_by_first_party_auth = bool(request.POST.get('email')) or bool(request.POST.get('password')) - was_authenticated_third_party = False - - try: - if third_party_auth_requested and not trumped_by_first_party_auth: - # The user has already authenticated via third-party auth and has not - # asked to do first party auth by supplying a username or password. We - # now want to put them through the same logging and cookie calculation - # logic as with first-party auth. - - # This nested try is due to us only returning an HttpResponse in this - # one case vs. JsonResponse everywhere else. - try: - email_user = _do_third_party_auth(request) - was_authenticated_third_party = True - except AuthFailedError as e: - return HttpResponse(e.value, content_type="text/plain", status=403) - else: - email_user = _get_user_by_email(request) - - _check_shib_redirect(email_user) - _check_excessive_login_attempts(email_user) - _check_forced_password_reset(email_user) - - possibly_authenticated_user = email_user - - if not was_authenticated_third_party: - possibly_authenticated_user = _authenticate_first_party(request, email_user) - if possibly_authenticated_user and password_policy_compliance.should_enforce_compliance_on_login(): - # Important: This call must be made AFTER the user was successfully authenticated. - _enforce_password_policy_compliance(request, possibly_authenticated_user) - - if possibly_authenticated_user is None or not possibly_authenticated_user.is_active: - _handle_failed_authentication(email_user) - - _handle_successful_authentication_and_login(possibly_authenticated_user, request) - - redirect_url = None # The AJAX method calling should know the default destination upon success - if was_authenticated_third_party: - running_pipeline = pipeline.get(request) - redirect_url = pipeline.get_complete_url(backend_name=running_pipeline['backend']) - - response = JsonResponse({ - 'success': True, - 'redirect_url': redirect_url, - }) - - # Ensure that the external marketing site can - # detect that the user is logged in. - return set_logged_in_cookies(request, response, possibly_authenticated_user) - except AuthFailedError as error: - return JsonResponse(error.get_response()) - - -@csrf_exempt -@require_POST -@social_utils.psa("social:complete") -def login_oauth_token(request, backend): - """ - Authenticate the client using an OAuth access token by using the token to - retrieve information from a third party and matching that information to an - existing user. - """ - warnings.warn("Please use AccessTokenExchangeView instead.", DeprecationWarning) - - backend = request.backend - if isinstance(backend, social_oauth.BaseOAuth1) or isinstance(backend, social_oauth.BaseOAuth2): - if "access_token" in request.POST: - # Tell third party auth pipeline that this is an API call - request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_LOGIN_API - user = None - access_token = request.POST["access_token"] - try: - user = backend.do_auth(access_token) - except (HTTPError, AuthException): - pass - # do_auth can return a non-User object if it fails - if user and isinstance(user, User): - django_login(request, user) - return JsonResponse(status=204) - else: - # Ensure user does not re-enter the pipeline - request.social_strategy.clean_partial_pipeline(access_token) - return JsonResponse({"error": "invalid_token"}, status=401) - else: - return JsonResponse({"error": "invalid_request"}, status=400) - raise Http404 - - -@ensure_csrf_cookie -def signin_user(request): - """Deprecated. To be replaced by :class:`student_account.views.login_and_registration_form`.""" - external_auth_response = external_auth_login(request) - if external_auth_response is not None: - return external_auth_response - # Determine the URL to redirect to following login: - redirect_to = get_next_url_for_login_page(request) - if request.user.is_authenticated: - return redirect(redirect_to) - - third_party_auth_error = None - for msg in messages.get_messages(request): - if msg.extra_tags.split()[0] == "social-auth": - # msg may or may not be translated. Try translating [again] in case we are able to: - third_party_auth_error = _(text_type(msg)) # pylint: disable=translation-of-non-string - break - - context = { - 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header - # Bool injected into JS to submit form if we're inside a running third- - # party auth pipeline; distinct from the actual instance of the running - # pipeline, if any. - 'pipeline_running': 'true' if pipeline.running(request) else 'false', - 'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to), - 'platform_name': configuration_helpers.get_value( - 'platform_name', - settings.PLATFORM_NAME - ), - 'third_party_auth_error': third_party_auth_error - } - - return render_to_response('login.html', context) - - -def str2bool(s): - s = str(s) - return s.lower() in ('yes', 'true', 't', '1') - - -def _clean_roles(roles): - """ Clean roles. - - Strips whitespace from roles, and removes empty items. - - Args: - roles (str[]): List of role names. - - Returns: - str[] - """ - roles = [role.strip() for role in roles] - roles = [role for role in roles if role] - return roles - - -def auto_auth(request): - """ - Create or configure a user account, then log in as that user. - - Enabled only when - settings.FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] is true. - - Accepts the following querystring parameters: - * `username`, `email`, and `password` for the user account - * `full_name` for the user profile (the user's full name; defaults to the username) - * `staff`: Set to "true" to make the user global staff. - * `course_id`: Enroll the student in the course with `course_id` - * `roles`: Comma-separated list of roles to grant the student in the course with `course_id` - * `no_login`: Define this to create the user but not login - * `redirect`: Set to "true" will redirect to the `redirect_to` value if set, or - course home page if course_id is defined, otherwise it will redirect to dashboard - * `redirect_to`: will redirect to to this url - * `is_active` : make/update account with status provided as 'is_active' - If username, email, or password are not provided, use - randomly generated credentials. - """ - - # Generate a unique name to use if none provided - generated_username = uuid.uuid4().hex[0:30] - generated_password = generate_password() - - # Use the params from the request, otherwise use these defaults - username = request.GET.get('username', generated_username) - password = request.GET.get('password', generated_password) - email = request.GET.get('email', username + "@example.com") - full_name = request.GET.get('full_name', username) - is_staff = str2bool(request.GET.get('staff', False)) - is_superuser = str2bool(request.GET.get('superuser', False)) - course_id = request.GET.get('course_id') - redirect_to = request.GET.get('redirect_to') - is_active = str2bool(request.GET.get('is_active', True)) - - # Valid modes: audit, credit, honor, no-id-professional, professional, verified - enrollment_mode = request.GET.get('enrollment_mode', 'honor') - - # Parse roles, stripping whitespace, and filtering out empty strings - roles = _clean_roles(request.GET.get('roles', '').split(',')) - course_access_roles = _clean_roles(request.GET.get('course_access_roles', '').split(',')) - - redirect_when_done = str2bool(request.GET.get('redirect', '')) or redirect_to - login_when_done = 'no_login' not in request.GET - - restricted = settings.FEATURES.get('RESTRICT_AUTOMATIC_AUTH', True) - if is_superuser and restricted: - return HttpResponseForbidden(_('Superuser creation not allowed')) - - form = AccountCreationForm( - data={ - 'username': username, - 'email': email, - 'password': password, - 'name': full_name, - }, - tos_required=False - ) - - # Attempt to create the account. - # If successful, this will return a tuple containing - # the new user object. - try: - user, profile, reg = do_create_account(form) - except (AccountValidationError, ValidationError): - if restricted: - return HttpResponseForbidden(_('Account modification not allowed.')) - # Attempt to retrieve the existing user. - user = User.objects.get(username=username) - user.email = email - user.set_password(password) - user.is_active = is_active - user.save() - profile = UserProfile.objects.get(user=user) - reg = Registration.objects.get(user=user) - except PermissionDenied: - return HttpResponseForbidden(_('Account creation not allowed.')) - - user.is_staff = is_staff - user.is_superuser = is_superuser - user.save() - - if is_active: - reg.activate() - reg.save() - - # ensure parental consent threshold is met - year = datetime.date.today().year - age_limit = settings.PARENTAL_CONSENT_AGE_LIMIT - profile.year_of_birth = (year - age_limit) - 1 - profile.save() - - create_or_set_user_attribute_created_on_site(user, request.site) - - # Enroll the user in a course - course_key = None - if course_id: - course_key = CourseLocator.from_string(course_id) - CourseEnrollment.enroll(user, course_key, mode=enrollment_mode) - - # Apply the roles - for role in roles: - assign_role(course_key, user, role) - - for role in course_access_roles: - CourseAccessRole.objects.update_or_create(user=user, course_id=course_key, org=course_key.org, role=role) - - # Log in as the user - if login_when_done: - user = authenticate_new_user(request, username, password) - django_login(request, user) - - create_comments_service_user(user) - - if redirect_when_done: - if redirect_to: - # Redirect to page specified by the client - redirect_url = redirect_to - elif course_id: - # Redirect to the course homepage (in LMS) or outline page (in Studio) - try: - redirect_url = reverse(course_home_url_name(course_key), kwargs={'course_id': course_id}) - except NoReverseMatch: - redirect_url = reverse('course_handler', kwargs={'course_key_string': course_id}) - else: - # Redirect to the learner dashboard (in LMS) or homepage (in Studio) - try: - redirect_url = reverse('dashboard') - except NoReverseMatch: - redirect_url = reverse('home') - - return redirect(redirect_url) - else: - response = JsonResponse({ - 'created_status': 'Logged in' if login_when_done else 'Created', - 'username': username, - 'email': email, - 'password': password, - 'user_id': user.id, # pylint: disable=no-member - 'anonymous_id': anonymous_id_for_user(user, None), - }) - response.set_cookie('csrftoken', csrf(request)['csrf_token']) - return response - - -class LogoutView(TemplateView): - """ - Logs out user and redirects. - - The template should load iframes to log the user out of OpenID Connect services. - See http://openid.net/specs/openid-connect-logout-1_0.html. - """ - oauth_client_ids = [] - template_name = 'logout.html' - - # Keep track of the page to which the user should ultimately be redirected. - default_target = reverse_lazy('cas-logout') if settings.FEATURES.get('AUTH_USE_CAS') else '/' - - @property - def target(self): - """ - If a redirect_url is specified in the querystring for this request, and the value is a url - with the same host, the view will redirect to this page after rendering the template. - If it is not specified, we will use the default target url. - """ - target_url = self.request.GET.get('redirect_url') - - if target_url and is_safe_url(target_url, allowed_hosts={self.request.META.get('HTTP_HOST')}, require_https=True): - return target_url - else: - return self.default_target - - def dispatch(self, request, *args, **kwargs): # pylint: disable=missing-docstring - # We do not log here, because we have a handler registered to perform logging on successful logouts. - request.is_from_logout = True - - # Get the list of authorized clients before we clear the session. - self.oauth_client_ids = request.session.get(edx_oauth2_provider.constants.AUTHORIZED_CLIENTS_SESSION_KEY, []) - - logout(request) - - # If we don't need to deal with OIDC logouts, just redirect the user. - if self.oauth_client_ids: - response = super(LogoutView, self).dispatch(request, *args, **kwargs) - else: - response = redirect(self.target) - - # Clear the cookie used by the edx.org marketing site - delete_logged_in_cookies(response) - - return response - - def _build_logout_url(self, url): - """ - Builds a logout URL with the `no_redirect` query string parameter. - - Args: - url (str): IDA logout URL - - Returns: - str - """ - scheme, netloc, path, query_string, fragment = urlsplit(url) - query_params = parse_qs(query_string) - query_params['no_redirect'] = 1 - new_query_string = urlencode(query_params, doseq=True) - return urlunsplit((scheme, netloc, path, new_query_string, fragment)) - - def get_context_data(self, **kwargs): - context = super(LogoutView, self).get_context_data(**kwargs) - - # Create a list of URIs that must be called to log the user out of all of the IDAs. - uris = Client.objects.filter(client_id__in=self.oauth_client_ids, - logout_uri__isnull=False).values_list('logout_uri', flat=True) - - referrer = self.request.META.get('HTTP_REFERER', '').strip('/') - logout_uris = [] - - for uri in uris: - if not referrer or (referrer and not uri.startswith(referrer)): - logout_uris.append(self._build_logout_url(uri)) - - context.update({ - 'target': self.target, - 'logout_uris': logout_uris, - }) - - return context diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index 891c9f6d35f0..a45003169001 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -3,19 +3,14 @@ """ import datetime -import json import logging import uuid -import warnings from collections import namedtuple -import analytics -import dogstats_wrapper as dog_stats_api from bulk_email.models import Optout from courseware.courses import get_courses, sort_by_announcement, sort_by_start_date from django.conf import settings from django.contrib import messages -from django.contrib.auth import login as django_login 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 @@ -31,60 +26,52 @@ from django.template.response import TemplateResponse from django.utils.encoding import force_bytes, force_text from django.utils.http import base36_to_int, urlsafe_base64_encode -from django.utils.translation import get_language, ungettext from django.utils.translation import ugettext as _ from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie -from django.views.decorators.http import require_GET, require_POST +from django.views.decorators.http import require_GET, require_POST, require_http_methods from edx_django_utils import monitoring as monitoring_utils from eventtracking import tracker from ipware.ip import get_ip # Note that this lives in LMS, so this dependency should be refactored. -from notification_prefs.views import enable_notifications from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from pytz import UTC -from requests import HTTPError -from six import text_type, iteritems -from social_core.exceptions import AuthAlreadyAssociated, AuthException -from social_django import utils as social_utils +from six import text_type from xmodule.modulestore.django import modulestore -import openedx.core.djangoapps.external_auth.views -import third_party_auth import track.views from course_modes.models import CourseMode +from edx_ace import ace +from edx_ace.recipient import Recipient from edxmako.shortcuts import render_to_response, render_to_string from entitlements.models import CourseEntitlement -from openedx.core.djangoapps.catalog.utils import ( - get_programs_with_type, -) + +from openedx.core.djangoapps.ace_common.template_context import get_base_template_context +from openedx.core.djangoapps.catalog.utils import get_programs_with_type from openedx.core.djangoapps.embargo import api as embargo_api -from openedx.core.djangoapps.external_auth.login_and_register import register as external_auth_register -from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY +from openedx.core.djangoapps.oauth_dispatch.api import destroy_oauth_tokens from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.theming import helpers as theming_helpers -from openedx.core.djangoapps.user_api import accounts as accounts_settings -from openedx.core.djangoapps.user_api.accounts.utils import generate_password +from openedx.core.djangoapps.theming.helpers import get_current_site +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 UserNotFound, UserAPIInternalError 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_api.config.waffle import PREVENT_AUTH_USER_WRITES, SYSTEM_MAINTENANCE_MSG, waffle + from openedx.core.djangolib.markup import HTML, Text from openedx.features.journals.api import get_journals_context -from student.cookies import set_logged_in_cookies from student.forms import AccountCreationForm, PasswordResetFormNoActive, get_registration_extension_form from student.helpers import ( DISABLE_UNENROLL_CERT_STATES, - AccountValidationError, auth_pipeline_urls, - authenticate_new_user, cert_info, create_or_set_user_attribute_created_on_site, - destroy_oauth_tokens, do_create_account, generate_activation_email_context, get_next_url_for_login_page ) +from student.message_types import PasswordReset from student.models import ( CourseEnrollment, PasswordHistory, @@ -101,8 +88,6 @@ from student.signals import REFUND_ORDER from student.tasks import send_activation_email from student.text_me_the_app import TextMeTheAppFragmentView -from third_party_auth import pipeline, provider -from third_party_auth.saml import SAP_SUCCESSFACTORS_SAML_KEY from util.bad_request_rate_limiter import BadRequestRateLimiter from util.db import outer_atomic from util.json_request import JsonResponse @@ -202,56 +187,6 @@ def index(request, extra_context=None, user=AnonymousUser()): return render_to_response('index.html', context) -@ensure_csrf_cookie -def register_user(request, extra_context=None): - """ - Deprecated. To be replaced by :class:`student_account.views.login_and_registration_form`. - """ - # Determine the URL to redirect to following login: - redirect_to = get_next_url_for_login_page(request) - if request.user.is_authenticated: - return redirect(redirect_to) - - external_auth_response = external_auth_register(request) - if external_auth_response is not None: - return external_auth_response - - context = { - 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header - 'email': '', - 'name': '', - 'running_pipeline': None, - 'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, redirect_url=redirect_to), - 'platform_name': configuration_helpers.get_value( - 'platform_name', - settings.PLATFORM_NAME - ), - 'selected_provider': '', - 'username': '', - } - - if extra_context is not None: - context.update(extra_context) - - if context.get("extauth_domain", '').startswith( - openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX - ): - return render_to_response('register-shib.html', context) - - # If third-party auth is enabled, prepopulate the form with data from the - # selected provider. - if third_party_auth.is_enabled() and pipeline.running(request): - running_pipeline = pipeline.get(request) - current_provider = provider.Registry.get_from_pipeline(running_pipeline) - if current_provider is not None: - overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs')) - overrides['running_pipeline'] = running_pipeline - overrides['selected_provider'] = current_provider.name - context.update(overrides) - - return render_to_response('register.html', context) - - def compose_and_send_activation_email(user, profile, user_registration=None): """ Construct all the required params and send the activation email @@ -279,6 +214,51 @@ def compose_and_send_activation_email(user, profile, user_registration=None): send_activation_email.delay(subject, message_for_activation, from_address, dest_addr) +def send_reactivation_email_for_user(user): + try: + registration = Registration.objects.get(user=user) + except Registration.DoesNotExist: + return JsonResponse({ + "success": False, + "error": _('No inactive user with this e-mail exists'), + }) + + try: + context = generate_activation_email_context(user, registration) + except ObjectDoesNotExist: + log.error( + u'Unable to send reactivation email due to unavailable profile for the user "%s"', + user.username, + exc_info=True + ) + return JsonResponse({ + "success": False, + "error": _('Unable to send reactivation email') + }) + + subject = render_to_string('emails/activation_email_subject.txt', context) + subject = ''.join(subject.splitlines()) + message = render_to_string('emails/activation_email.txt', context) + from_address = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) + from_address = configuration_helpers.get_value('ACTIVATION_EMAIL_FROM_ADDRESS', from_address) + + try: + user.email_user(subject, message, from_address) + except Exception: # pylint: disable=broad-except + log.error( + u'Unable to send reactivation email from "%s" to "%s"', + from_address, + user.email, + exc_info=True + ) + return JsonResponse({ + "success": False, + "error": _('Unable to send reactivation email') + }) + + return JsonResponse({"success": True}) + + @login_required def course_run_refund_status(request, course_id): """ @@ -562,421 +542,6 @@ def user_signup_handler(sender, **kwargs): # pylint: disable=unused-argument log.info(u'user {} originated from a white labeled "Microsite"'.format(kwargs['instance'].id)) -@transaction.non_atomic_requests -def create_account_with_params(request, params): - """ - Given a request and a dict of parameters (which may or may not have come - from the request), create an account for the requesting user, including - creating a comments service user object and sending an activation email. - This also takes external/third-party auth into account, updates that as - necessary, and authenticates the user for the request's session. - - Does not return anything. - - Raises AccountValidationError if an account with the username or email - specified by params already exists, or ValidationError if any of the given - parameters is invalid for any other reason. - - Issues with this code: - * It is non-transactional except where explicitly wrapped in atomic to - alleviate deadlocks and improve performance. This means failures at - different places in registration can leave users in inconsistent - states. - * Third-party auth passwords are not verified. There is a comment that - they are unused, but it would be helpful to have a sanity check that - they are sane. - * The user-facing text is rather unfriendly (e.g. "Username must be a - minimum of two characters long" rather than "Please use a username of - at least two characters"). - * Duplicate email raises a ValidationError (rather than the expected - AccountValidationError). Duplicate username returns an inconsistent - user message (i.e. "An account with the Public Username '{username}' - already exists." rather than "It looks like {username} belongs to an - existing account. Try again with a different username.") The two checks - occur at different places in the code; as a result, registering with - both a duplicate username and email raises only a ValidationError for - email only. - """ - # Copy params so we can modify it; we can't just do dict(params) because if - # params is request.POST, that results in a dict containing lists of values - params = dict(params.items()) - - # allow to define custom set of required/optional/hidden fields via configuration - extra_fields = configuration_helpers.get_value( - 'REGISTRATION_EXTRA_FIELDS', - getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {}) - ) - # registration via third party (Google, Facebook) using mobile application - # doesn't use social auth pipeline (no redirect uri(s) etc involved). - # In this case all related info (required for account linking) - # is sent in params. - # `third_party_auth_credentials_in_api` essentially means 'request - # is made from mobile application' - third_party_auth_credentials_in_api = 'provider' in params - - is_third_party_auth_enabled = third_party_auth.is_enabled() - - if is_third_party_auth_enabled and (pipeline.running(request) or third_party_auth_credentials_in_api): - params["password"] = generate_password() - - # in case user is registering via third party (Google, Facebook) and pipeline has expired, show appropriate - # error message - if is_third_party_auth_enabled and ('social_auth_provider' in params and not pipeline.running(request)): - raise ValidationError( - {'session_expired': [ - _(u"Registration using {provider} has timed out.").format( - provider=params.get('social_auth_provider')) - ]} - ) - - # if doing signup for an external authorization, then get email, password, name from the eamap - # don't use the ones from the form, since the user could have hacked those - # unless originally we didn't get a valid email or name from the external auth - # TODO: We do not check whether these values meet all necessary criteria, such as email length - do_external_auth = 'ExternalAuthMap' in request.session - if do_external_auth: - eamap = request.session['ExternalAuthMap'] - try: - validate_email(eamap.external_email) - params["email"] = eamap.external_email - except ValidationError: - pass - if len(eamap.external_name.strip()) >= accounts_settings.NAME_MIN_LENGTH: - params["name"] = eamap.external_name - params["password"] = eamap.internal_password - log.debug(u'In create_account with external_auth: user = %s, email=%s', params["name"], params["email"]) - - extended_profile_fields = configuration_helpers.get_value('extended_profile_fields', []) - enforce_password_policy = not do_external_auth - # Can't have terms of service for certain SHIB users, like at Stanford - registration_fields = getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {}) - tos_required = ( - registration_fields.get('terms_of_service') != 'hidden' or - registration_fields.get('honor_code') != 'hidden' - ) and ( - not settings.FEATURES.get("AUTH_USE_SHIB") or - not settings.FEATURES.get("SHIB_DISABLE_TOS") or - not do_external_auth or - not eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX) - ) - - form = AccountCreationForm( - data=params, - extra_fields=extra_fields, - extended_profile_fields=extended_profile_fields, - enforce_password_policy=enforce_password_policy, - tos_required=tos_required, - ) - custom_form = get_registration_extension_form(data=params) - - third_party_provider = None - running_pipeline = None - new_user = None - - # Perform operations within a transaction that are critical to account creation - with outer_atomic(read_committed=True): - # first, create the account - (user, profile, registration) = do_create_account(form, custom_form) - - # If a 3rd party auth provider and credentials were provided in the API, link the account with social auth - # (If the user is using the normal register page, the social auth pipeline does the linking, not this code) - - # Note: this is orthogonal to the 3rd party authentication pipeline that occurs - # when the account is created via the browser and redirect URLs. - - if is_third_party_auth_enabled and third_party_auth_credentials_in_api: - backend_name = params['provider'] - request.social_strategy = social_utils.load_strategy(request) - redirect_uri = reverse('social:complete', args=(backend_name, )) - request.backend = social_utils.load_backend(request.social_strategy, backend_name, redirect_uri) - social_access_token = params.get('access_token') - if not social_access_token: - raise ValidationError({ - 'access_token': [ - _("An access_token is required when passing value ({}) for provider.").format( - params['provider'] - ) - ] - }) - request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_REGISTER_API - pipeline_user = None - error_message = "" - try: - pipeline_user = request.backend.do_auth(social_access_token, user=user) - except AuthAlreadyAssociated: - error_message = _("The provided access_token is already associated with another user.") - except (HTTPError, AuthException): - error_message = _("The provided access_token is not valid.") - if not pipeline_user or not isinstance(pipeline_user, User): - # Ensure user does not re-enter the pipeline - request.social_strategy.clean_partial_pipeline(social_access_token) - raise ValidationError({'access_token': [error_message]}) - - # If the user is registering via 3rd party auth, track which provider they use - if is_third_party_auth_enabled and pipeline.running(request): - running_pipeline = pipeline.get(request) - third_party_provider = provider.Registry.get_from_pipeline(running_pipeline) - - new_user = authenticate_new_user(request, user.username, params['password']) - django_login(request, new_user) - request.session.set_expiry(0) - - if do_external_auth: - eamap.user = new_user - eamap.dtsignup = datetime.datetime.now(UTC) - eamap.save() - AUDIT_LOG.info(u"User registered with external_auth %s", new_user.username) - AUDIT_LOG.info(u'Updated ExternalAuthMap for %s to be %s', new_user.username, eamap) - - if settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'): - log.info('bypassing activation email') - new_user.is_active = True - new_user.save() - AUDIT_LOG.info( - u"Login activated on extauth account - {0} ({1})".format(new_user.username, new_user.email)) - - # Check if system is configured to skip activation email for the current user. - skip_email = skip_activation_email( - user, do_external_auth, running_pipeline, third_party_provider, - ) - - if skip_email: - registration.activate() - else: - compose_and_send_activation_email(user, profile, registration) - - # Perform operations that are non-critical parts of account creation - create_or_set_user_attribute_created_on_site(user, request.site) - - preferences_api.set_user_preference(user, LANGUAGE_KEY, get_language()) - - if settings.FEATURES.get('ENABLE_DISCUSSION_EMAIL_DIGEST'): - try: - enable_notifications(user) - except Exception: # pylint: disable=broad-except - log.exception("Enable discussion notifications failed for user {id}.".format(id=user.id)) - - dog_stats_api.increment("common.student.account_created") - - # Track the user's registration - if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: - tracking_context = tracker.get_tracker().resolve_context() - identity_args = [ - user.id, - { - 'email': user.email, - 'username': user.username, - 'name': profile.name, - # Mailchimp requires the age & yearOfBirth to be integers, we send a sane integer default if falsey. - 'age': profile.age or -1, - 'yearOfBirth': profile.year_of_birth or datetime.datetime.now(UTC).year, - 'education': profile.level_of_education_display, - 'address': profile.mailing_address, - 'gender': profile.gender_display, - 'country': text_type(profile.country), - } - ] - - if hasattr(settings, 'MAILCHIMP_NEW_USER_LIST_ID'): - identity_args.append({ - "MailChimp": { - "listId": settings.MAILCHIMP_NEW_USER_LIST_ID - } - }) - - analytics.identify(*identity_args) - - analytics.track( - user.id, - "edx.bi.user.account.registered", - { - 'category': 'conversion', - 'label': params.get('course_id'), - 'provider': third_party_provider.name if third_party_provider else None - }, - context={ - 'ip': tracking_context.get('ip'), - 'Google Analytics': { - 'clientId': tracking_context.get('client_id') - } - } - ) - - # Announce registration - REGISTER_USER.send(sender=None, user=user, registration=registration) - - create_comments_service_user(user) - - try: - record_registration_attributions(request, new_user) - # Don't prevent a user from registering due to attribution errors. - except Exception: # pylint: disable=broad-except - log.exception('Error while attributing cookies to user registration.') - - # TODO: there is no error checking here to see that the user actually logged in successfully, - # and is not yet an active user. - if new_user is not None: - AUDIT_LOG.info(u"Login success on new account creation - {0}".format(new_user.username)) - - return new_user - - -def skip_activation_email(user, do_external_auth, running_pipeline, third_party_provider): - """ - Return `True` if activation email should be skipped. - - Skip email if we are: - 1. Doing load testing. - 2. Random user generation for other forms of testing. - 3. External auth bypassing activation. - 4. Have the platform configured to not require e-mail activation. - 5. Registering a new user using a trusted third party provider (with skip_email_verification=True) - - Note that this feature is only tested as a flag set one way or - the other for *new* systems. we need to be careful about - changing settings on a running system to make sure no users are - left in an inconsistent state (or doing a migration if they are). - - Arguments: - user (User): Django User object for the current user. - do_external_auth (bool): True if external authentication is in progress. - running_pipeline (dict): Dictionary containing user and pipeline data for third party authentication. - third_party_provider (ProviderConfig): An instance of third party provider configuration. - - Returns: - (bool): `True` if account activation email should be skipped, `False` if account activation email should be - sent. - """ - sso_pipeline_email = running_pipeline and running_pipeline['kwargs'].get('details', {}).get('email') - - # Email is valid if the SAML assertion email matches the user account email or - # no email was provided in the SAML assertion. Some IdP's use a callback - # to retrieve additional user account information (including email) after the - # initial account creation. - valid_email = ( - sso_pipeline_email == user.email or ( - sso_pipeline_email is None and - third_party_provider and - getattr(third_party_provider, "identity_provider_type", None) == SAP_SUCCESSFACTORS_SAML_KEY - ) - ) - - # log the cases where skip activation email flag is set, but email validity check fails - if third_party_provider and third_party_provider.skip_email_verification and not valid_email: - log.info( - '[skip_email_verification=True][user=%s][pipeline-email=%s][identity_provider=%s][provider_type=%s] ' - 'Account activation email sent as user\'s system email differs from SSO email.', - user.email, - sso_pipeline_email, - getattr(third_party_provider, "provider_id", None), - getattr(third_party_provider, "identity_provider_type", None) - ) - - return ( - settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) or - settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') or - (settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH') and do_external_auth) or - (third_party_provider and third_party_provider.skip_email_verification and valid_email) - ) - - -def record_affiliate_registration_attribution(request, user): - """ - Attribute this user's registration to the referring affiliate, if - applicable. - """ - affiliate_id = request.COOKIES.get(settings.AFFILIATE_COOKIE_NAME) - if user and affiliate_id: - UserAttribute.set_user_attribute(user, REGISTRATION_AFFILIATE_ID, affiliate_id) - - -def record_utm_registration_attribution(request, user): - """ - Attribute this user's registration to the latest UTM referrer, if - applicable. - """ - utm_cookie_name = RegistrationCookieConfiguration.current().utm_cookie_name - utm_cookie = request.COOKIES.get(utm_cookie_name) - if user and utm_cookie: - utm = json.loads(utm_cookie) - for utm_parameter_name in REGISTRATION_UTM_PARAMETERS: - utm_parameter = utm.get(utm_parameter_name) - if utm_parameter: - UserAttribute.set_user_attribute( - user, - REGISTRATION_UTM_PARAMETERS.get(utm_parameter_name), - utm_parameter - ) - created_at_unixtime = utm.get('created_at') - if created_at_unixtime: - # We divide by 1000 here because the javascript timestamp generated is in milliseconds not seconds. - # PYTHON: time.time() => 1475590280.823698 - # JS: new Date().getTime() => 1475590280823 - created_at_datetime = datetime.datetime.fromtimestamp(int(created_at_unixtime) / float(1000), tz=UTC) - UserAttribute.set_user_attribute( - user, - REGISTRATION_UTM_CREATED_AT, - created_at_datetime - ) - - -def record_registration_attributions(request, user): - """ - Attribute this user's registration based on referrer cookies. - """ - record_affiliate_registration_attribution(request, user) - record_utm_registration_attribution(request, user) - - -@csrf_exempt -@transaction.non_atomic_requests -def create_account(request, post_override=None): - """ - JSON call to create new edX account. - Used by form in signup_modal.html, which is included into header.html - """ - # Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation - if not configuration_helpers.get_value( - 'ALLOW_PUBLIC_ACCOUNT_CREATION', - settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True) - ): - return HttpResponseForbidden(_("Account creation not allowed.")) - - if waffle().is_enabled(PREVENT_AUTH_USER_WRITES): - return HttpResponseForbidden(SYSTEM_MAINTENANCE_MSG) - - warnings.warn("Please use RegistrationView instead.", DeprecationWarning) - - try: - user = create_account_with_params(request, post_override or request.POST) - except AccountValidationError as exc: - return JsonResponse({'success': False, 'value': text_type(exc), 'field': exc.field}, status=400) - except ValidationError as exc: - field, error_list = next(iteritems(exc.message_dict)) - return JsonResponse( - { - "success": False, - "field": field, - "value": error_list[0], - }, - status=400 - ) - - redirect_url = None # The AJAX method calling should know the default destination upon success - - # Resume the third-party-auth pipeline if necessary. - if third_party_auth.is_enabled() and pipeline.running(request): - running_pipeline = pipeline.get(request) - redirect_url = pipeline.get_complete_url(running_pipeline['backend']) - - response = JsonResponse({ - 'success': True, - 'redirect_url': redirect_url, - }) - set_logged_in_cookies(request, response, user) - return response - - @ensure_csrf_cookie def activate_account(request, key): """ @@ -1078,6 +643,83 @@ 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 + + """ + + limiter = BadRequestRateLimiter() + if limiter.is_rate_limit_exceeded(request): + AUDIT_LOG.warning("Password reset rate limit exceeded") + return HttpResponseForbidden() + + 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_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) + destroy_oauth_tokens(user) + except UserNotFound: + AUDIT_LOG.info("Invalid password reset attempt") + # Increment the rate limit counter + limiter.tick_bad_request_counter(request) + + # 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) + + return HttpResponse(status=200) + else: + return HttpResponseBadRequest(_("No email address provided.")) + + @csrf_exempt @require_POST def password_reset(request): diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index 920eab66e66f..1a6ff94ab0b0 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -263,8 +263,8 @@ def get_social_auth_uid(self, remote_id): def get_register_form_data(cls, pipeline_kwargs): """Gets dict of data to display on the register form. - common.djangoapps.student.views.register_user uses this to populate the - new account creation form with values supplied by the user's chosen + openedx.core.djangoapps.user_authn.views.deprecated.register_user uses this to populate + the new account creation form with values supplied by the user's chosen provider, preventing duplicate data entry. Args: diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index abb187c9d9b5..4682cdab725b 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -78,13 +78,13 @@ def B(*args, **kwargs): from social_core.pipeline import partial from social_core.pipeline.social_auth import associate_by_email -import student from edxmako.shortcuts import render_to_string from eventtracking import tracker from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from third_party_auth.utils import user_exists +from openedx.core.djangoapps.user_authn import cookies as user_authn_cookies from lms.djangoapps.verify_student.models import SSOVerification from lms.djangoapps.verify_student.utils import earliest_allowed_verification_date +from third_party_auth.utils import user_exists from . import provider @@ -633,7 +633,7 @@ def set_logged_in_cookies(backend=None, user=None, strategy=None, auth_entry=Non # Check that the cookie isn't already set. # This ensures that we allow the user to continue to the next # pipeline step once he/she has the cookie set by this step. - has_cookie = student.cookies.is_logged_in_cookie_set(request) + has_cookie = user_authn_cookies.is_logged_in_cookie_set(request) if not has_cookie: try: redirect_url = get_complete_url(current_partial.backend) @@ -644,7 +644,7 @@ def set_logged_in_cookies(backend=None, user=None, strategy=None, auth_entry=Non pass else: response = redirect(redirect_url) - return student.cookies.set_logged_in_cookies(request, response, user) + return user_authn_cookies.set_logged_in_cookies(request, response, user) @partial.partial diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index f9dbc69cb4d8..dcc943515b44 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -20,11 +20,12 @@ from social_django import views as social_views from lms.djangoapps.commerce.tests import TEST_API_URL +from openedx.core.djangoapps.user_authn.views.deprecated import signin_user, create_account, register_user +from openedx.core.djangoapps.user_authn.views.login import login_user from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory +from openedx.core.djangoapps.user_api.accounts.settings_views import account_settings_context from student import models as student_models -from student import views as student_views from student.tests.factories import UserFactory -from student_account.views import account_settings_context from third_party_auth import middleware, pipeline from third_party_auth.tests import testutil @@ -538,8 +539,8 @@ def test_full_pipeline_succeeds_for_linking_account(self): actions.do_complete(request.backend, social_views._do_login, # pylint: disable=protected-access request=request) - student_views.signin_user(strategy.request) - student_views.login_user(strategy.request) + signin_user(strategy.request) + login_user(strategy.request) actions.do_complete(request.backend, social_views._do_login, # pylint: disable=protected-access request=request) @@ -595,8 +596,8 @@ def test_full_pipeline_succeeds_for_unlinking_account(self): request=request) with self._patch_edxmako_current_request(strategy.request): - student_views.signin_user(strategy.request) - student_views.login_user(strategy.request) + signin_user(strategy.request) + login_user(strategy.request) actions.do_complete(request.backend, social_views._do_login, user=user, # pylint: disable=protected-access request=request) @@ -662,8 +663,8 @@ def test_already_associated_exception_populates_dashboard_with_error(self): request=request) with self._patch_edxmako_current_request(strategy.request): - student_views.signin_user(strategy.request) - student_views.login_user(strategy.request) + signin_user(strategy.request) + login_user(strategy.request) actions.do_complete(request.backend, social_views._do_login, # pylint: disable=protected-access user=user, request=request) @@ -707,12 +708,12 @@ def test_full_pipeline_succeeds_for_signing_in_to_existing_active_account(self): # At this point we know the pipeline has resumed correctly. Next we # fire off the view that displays the login form and posts it via JS. with self._patch_edxmako_current_request(strategy.request): - self.assert_login_response_in_pipeline_looks_correct(student_views.signin_user(strategy.request)) + self.assert_login_response_in_pipeline_looks_correct(signin_user(strategy.request)) # Next, we invoke the view that handles the POST, and expect it # redirects to /auth/complete. In the browser ajax handlers will # redirect the user to the dashboard; we invoke it manually here. - self.assert_json_success_response_looks_correct(student_views.login_user(strategy.request)) + self.assert_json_success_response_looks_correct(login_user(strategy.request)) # We should be redirected back to the complete page, setting # the "logged in" cookie for the marketing site. @@ -739,7 +740,7 @@ def test_signin_fails_if_account_not_active(self): user.save() with self._patch_edxmako_current_request(strategy.request): - self.assert_json_failure_response_is_inactive_account(student_views.login_user(strategy.request)) + self.assert_json_failure_response_is_inactive_account(login_user(strategy.request)) def test_signin_fails_if_no_account_associated(self): _, strategy = self.get_request_and_strategy( @@ -748,7 +749,7 @@ def test_signin_fails_if_no_account_associated(self): self.create_user_models_for_existing_account( strategy, 'user@example.com', 'password', self.get_username(), skip_social_auth=True) - self.assert_json_failure_response_is_missing_social_auth(student_views.login_user(strategy.request)) + self.assert_json_failure_response_is_missing_social_auth(login_user(strategy.request)) def test_first_party_auth_trumps_third_party_auth_but_is_invalid_when_only_email_in_request(self): self.assert_first_party_auth_trumps_third_party_auth(email='user@example.com') @@ -789,7 +790,7 @@ def test_full_pipeline_succeeds_registering_new_account(self): # fire off the view that displays the registration form. with self._patch_edxmako_current_request(request): self.assert_register_response_in_pipeline_looks_correct( - student_views.register_user(strategy.request), + register_user(strategy.request), pipeline.get(request)['kwargs'], ['name', 'username', 'email'] ) @@ -811,7 +812,7 @@ def test_full_pipeline_succeeds_registering_new_account(self): # ...but when we invoke create_account the existing edX view will make # it, but not social auths. The pipeline creates those later. with self._patch_edxmako_current_request(strategy.request): - self.assert_json_success_response_looks_correct(student_views.create_account(strategy.request)) + self.assert_json_success_response_looks_correct(create_account(strategy.request)) # We've overridden the user's password, so authenticate() with the old # value won't work: created_user = self.get_user_by_email(strategy, email) @@ -864,7 +865,7 @@ def test_new_account_registration_fails_if_email_exists(self): with self._patch_edxmako_current_request(request): self.assert_register_response_in_pipeline_looks_correct( - student_views.register_user(strategy.request), + register_user(strategy.request), pipeline.get(request)['kwargs'], ['name', 'username', 'email'] ) @@ -872,8 +873,8 @@ def test_new_account_registration_fails_if_email_exists(self): with self._patch_edxmako_current_request(strategy.request): strategy.request.POST = self.get_registration_post_vars() # Create twice: once successfully, and once causing a collision. - student_views.create_account(strategy.request) - self.assert_json_failure_response_is_username_collision(student_views.create_account(strategy.request)) + create_account(strategy.request) + self.assert_json_failure_response_is_username_collision(create_account(strategy.request)) def test_pipeline_raises_auth_entry_error_if_auth_entry_invalid(self): auth_entry = 'invalid' @@ -914,7 +915,7 @@ def assert_first_party_auth_trumps_third_party_auth(self, email=None, password=N strategy.request.POST['password'] = 'bad_' + password if success is False else password self.assert_pipeline_running(strategy.request) - payload = json.loads(student_views.login_user(strategy.request).content) + payload = json.loads(login_user(strategy.request).content) if success is None: # Request malformed -- just one of email/password given. diff --git a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py index d21c845bf5ee..fa0db7c25fef 100644 --- a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py +++ b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py @@ -18,9 +18,10 @@ from testfixtures import LogCapture from enterprise.models import EnterpriseCustomerIdentityProvider, EnterpriseCustomerUser +from openedx.core.djangoapps.user_authn.views.deprecated import signin_user +from openedx.core.djangoapps.user_authn.views.login import login_user +from openedx.core.djangoapps.user_api.accounts.settings_views import account_settings_context from openedx.features.enterprise_support.tests.factories import EnterpriseCustomerFactory -from student import views as student_views -from student_account.views import account_settings_context from third_party_auth import pipeline from third_party_auth.saml import SapSuccessFactorsIdentityProvider, log as saml_log from third_party_auth.tasks import fetch_saml_metadata @@ -183,8 +184,8 @@ def test_full_pipeline_succeeds_for_unlinking_testshib_account(self): request=request) with self._patch_edxmako_current_request(strategy.request): - student_views.signin_user(strategy.request) - student_views.login_user(strategy.request) + signin_user(strategy.request) + login_user(strategy.request) actions.do_complete(request.backend, social_views._do_login, user=user, # pylint: disable=protected-access request=request) diff --git a/lms/djangoapps/email_marketing/signals.py b/lms/djangoapps/email_marketing/signals.py index 157cd6fc59e1..f04d2e79b69b 100644 --- a/lms/djangoapps/email_marketing/signals.py +++ b/lms/djangoapps/email_marketing/signals.py @@ -17,10 +17,10 @@ from course_modes.models import CourseMode from email_marketing.models import EmailMarketingConfiguration from lms.djangoapps.email_marketing.tasks import get_email_cookies_via_sailthru, update_user, update_user_email +from openedx.core.djangoapps.user_authn.cookies import CREATE_LOGON_COOKIE from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_THIRD_PARTY_MAILINGS from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace -from student.cookies import CREATE_LOGON_COOKIE from student.signals import SAILTHRU_AUDIT_PURCHASE from student.views import REGISTER_USER from util.model_utils import USER_FIELD_CHANGED diff --git a/lms/djangoapps/student_account/urls.py b/lms/djangoapps/student_account/urls.py deleted file mode 100644 index 621f7e45a727..000000000000 --- a/lms/djangoapps/student_account/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -from django.conf import settings -from django.conf.urls import url - -from student_account import views - -urlpatterns = [ - url(r'^finish_auth$', views.finish_auth, name='finish_auth'), - url(r'^settings$', views.account_settings, name='account_settings'), -] - -if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'): - urlpatterns += [ - url(r'^password$', views.password_change_request_handler, name='password_change_request'), - ] diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py deleted file mode 100644 index 203b3adb2ae6..000000000000 --- a/lms/djangoapps/student_account/views.py +++ /dev/null @@ -1,606 +0,0 @@ -""" Views for a student's account information. """ - -import json -import logging -from datetime import datetime - -import urlparse -from django.conf import settings -from django.contrib import messages -from django.contrib.auth import get_user_model -from django.contrib.auth.decorators import login_required -from django.urls import reverse -from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden -from django.shortcuts import redirect -from django.utils.translation import ugettext as _ -from django.views.decorators.csrf import ensure_csrf_cookie -from django.views.decorators.http import require_http_methods -from django_countries import countries -import third_party_auth - -from edx_ace import ace -from edx_ace.recipient import Recipient -from edxmako.shortcuts import render_to_response -from lms.djangoapps.commerce.models import CommerceConfiguration -from lms.djangoapps.commerce.utils import EcommerceService -from openedx.core.djangoapps.ace_common.template_context import get_base_template_context -from openedx.core.djangoapps.commerce.utils import ecommerce_api_client -from openedx.core.djangoapps.external_auth.login_and_register import login as external_auth_login -from openedx.core.djangoapps.external_auth.login_and_register import register as external_auth_register -from openedx.core.djangoapps.lang_pref.api import all_languages, released_languages -from openedx.core.djangoapps.programs.models import ProgramsApiConfig -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.core.djangoapps.theming.helpers import is_request_in_themed_site, get_current_site -from openedx.core.djangoapps.user_api.accounts.api import request_password_change -from openedx.core.djangoapps.user_api.api import ( - RegistrationFormFactory, - get_login_session_form, - get_password_reset_form -) -from openedx.core.djangoapps.user_api.errors import ( - UserNotFound, - UserAPIInternalError -) -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.features.enterprise_support.api import enterprise_customer_for_request, get_enterprise_customer_for_learner -from openedx.features.enterprise_support.utils import ( - handle_enterprise_cookies_for_logistration, - update_logistration_context_for_enterprise, - update_account_settings_context_for_enterprise, -) -from student.helpers import destroy_oauth_tokens, get_next_url_for_login_page -from student.message_types import PasswordReset -from student.models import UserProfile -from student.views import register_user as old_register_view, signin_user as old_login_view -from third_party_auth import pipeline -from third_party_auth.decorators import xframe_allow_whitelisted -from util.bad_request_rate_limiter import BadRequestRateLimiter -from util.date_utils import strftime_localized - - -AUDIT_LOG = logging.getLogger("audit") -log = logging.getLogger(__name__) -User = get_user_model() # pylint:disable=invalid-name - - -@require_http_methods(['GET']) -@ensure_csrf_cookie -@xframe_allow_whitelisted -def login_and_registration_form(request, initial_mode="login"): - """Render the combined login/registration form, defaulting to login - - This relies on the JS to asynchronously load the actual form from - the user_api. - - Keyword Args: - initial_mode (string): Either "login" or "register". - - """ - # Determine the URL to redirect to following login/registration/third_party_auth - redirect_to = get_next_url_for_login_page(request) - # If we're already logged in, redirect to the dashboard - if request.user.is_authenticated: - return redirect(redirect_to) - - # Retrieve the form descriptions from the user API - form_descriptions = _get_form_descriptions(request) - - # Our ?next= URL may itself contain a parameter 'tpa_hint=x' that we need to check. - # If present, we display a login page focused on third-party auth with that provider. - third_party_auth_hint = None - if '?' in redirect_to: - try: - next_args = urlparse.parse_qs(urlparse.urlparse(redirect_to).query) - provider_id = next_args['tpa_hint'][0] - tpa_hint_provider = third_party_auth.provider.Registry.get(provider_id=provider_id) - if tpa_hint_provider: - if tpa_hint_provider.skip_hinted_login_dialog: - # Forward the user directly to the provider's login URL when the provider is configured - # to skip the dialog. - if initial_mode == "register": - auth_entry = pipeline.AUTH_ENTRY_REGISTER - else: - auth_entry = pipeline.AUTH_ENTRY_LOGIN - return redirect( - pipeline.get_login_url(provider_id, auth_entry, redirect_url=redirect_to) - ) - third_party_auth_hint = provider_id - initial_mode = "hinted_login" - except (KeyError, ValueError, IndexError) as ex: - log.error("Unknown tpa_hint provider: %s", ex) - - # If this is a themed site, revert to the old login/registration pages. - # We need to do this for now to support existing themes. - # Themed sites can use the new logistration page by setting - # 'ENABLE_COMBINED_LOGIN_REGISTRATION' in their - # configuration settings. - if is_request_in_themed_site() and not configuration_helpers.get_value('ENABLE_COMBINED_LOGIN_REGISTRATION', False): - if initial_mode == "login": - return old_login_view(request) - elif initial_mode == "register": - return old_register_view(request) - - # Allow external auth to intercept and handle the request - ext_auth_response = _external_auth_intercept(request, initial_mode) - if ext_auth_response is not None: - return ext_auth_response - - # Account activation message - account_activation_messages = [ - { - 'message': message.message, 'tags': message.tags - } for message in messages.get_messages(request) if 'account-activation' in message.tags - ] - - # Otherwise, render the combined login/registration page - context = { - 'data': { - 'login_redirect_url': redirect_to, - 'initial_mode': initial_mode, - 'third_party_auth': _third_party_auth_context(request, redirect_to, third_party_auth_hint), - 'third_party_auth_hint': third_party_auth_hint or '', - 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), - 'support_link': configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK), - 'password_reset_support_link': configuration_helpers.get_value( - 'PASSWORD_RESET_SUPPORT_LINK', settings.PASSWORD_RESET_SUPPORT_LINK - ) or settings.SUPPORT_SITE_LINK, - 'account_activation_messages': account_activation_messages, - - # Include form descriptions retrieved from the user API. - # We could have the JS client make these requests directly, - # but we include them in the initial page load to avoid - # the additional round-trip to the server. - 'login_form_desc': json.loads(form_descriptions['login']), - 'registration_form_desc': json.loads(form_descriptions['registration']), - 'password_reset_form_desc': json.loads(form_descriptions['password_reset']), - 'account_creation_allowed': configuration_helpers.get_value( - 'ALLOW_PUBLIC_ACCOUNT_CREATION', settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True)) - }, - 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in header - 'responsive': True, - 'allow_iframing': True, - 'disable_courseware_js': True, - 'combined_login_and_register': True, - 'disable_footer': not configuration_helpers.get_value( - 'ENABLE_COMBINED_LOGIN_REGISTRATION_FOOTER', - settings.FEATURES['ENABLE_COMBINED_LOGIN_REGISTRATION_FOOTER'] - ), - } - - enterprise_customer = enterprise_customer_for_request(request) - update_logistration_context_for_enterprise(request, context, enterprise_customer) - - response = render_to_response('student_account/login_and_register.html', context) - handle_enterprise_cookies_for_logistration(request, response, context) - - return response - - -@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 - - """ - - limiter = BadRequestRateLimiter() - if limiter.is_rate_limit_exceeded(request): - AUDIT_LOG.warning("Password reset rate limit exceeded") - return HttpResponseForbidden() - - 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 User.objects.get(email=email) - destroy_oauth_tokens(user) - except UserNotFound: - AUDIT_LOG.info("Invalid password reset attempt") - # Increment the rate limit counter - limiter.tick_bad_request_counter(request) - - # 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) - - return HttpResponse(status=200) - else: - return HttpResponseBadRequest(_("No email address provided.")) - - -def _third_party_auth_context(request, redirect_to, tpa_hint=None): - """Context for third party auth providers and the currently running pipeline. - - Arguments: - request (HttpRequest): The request, used to determine if a pipeline - is currently running. - redirect_to: The URL to send the user to following successful - authentication. - tpa_hint (string): An override flag that will return a matching provider - as long as its configuration has been enabled - - Returns: - dict - - """ - context = { - "currentProvider": None, - "providers": [], - "secondaryProviders": [], - "finishAuthUrl": None, - "errorMessage": None, - "registerFormSubmitButtonText": _("Create Account"), - "syncLearnerProfileData": False, - "pipeline_user_details": {} - } - - if third_party_auth.is_enabled(): - for enabled in third_party_auth.provider.Registry.displayed_for_login(tpa_hint=tpa_hint): - info = { - "id": enabled.provider_id, - "name": enabled.name, - "iconClass": enabled.icon_class or None, - "iconImage": enabled.icon_image.url if enabled.icon_image else None, - "loginUrl": pipeline.get_login_url( - enabled.provider_id, - pipeline.AUTH_ENTRY_LOGIN, - redirect_url=redirect_to, - ), - "registerUrl": pipeline.get_login_url( - enabled.provider_id, - pipeline.AUTH_ENTRY_REGISTER, - redirect_url=redirect_to, - ), - } - context["providers" if not enabled.secondary else "secondaryProviders"].append(info) - - running_pipeline = pipeline.get(request) - if running_pipeline is not None: - current_provider = third_party_auth.provider.Registry.get_from_pipeline(running_pipeline) - user_details = running_pipeline['kwargs']['details'] - if user_details: - context['pipeline_user_details'] = user_details - - if current_provider is not None: - context["currentProvider"] = current_provider.name - context["finishAuthUrl"] = pipeline.get_complete_url(current_provider.backend_name) - context["syncLearnerProfileData"] = current_provider.sync_learner_profile_data - - if current_provider.skip_registration_form: - # As a reliable way of "skipping" the registration form, we just submit it automatically - context["autoSubmitRegForm"] = True - - # Check for any error messages we may want to display: - for msg in messages.get_messages(request): - if msg.extra_tags.split()[0] == "social-auth": - # msg may or may not be translated. Try translating [again] in case we are able to: - context['errorMessage'] = _(unicode(msg)) - break - - return context - - -def _get_form_descriptions(request): - """Retrieve form descriptions from the user API. - - Arguments: - request (HttpRequest): The original request, used to retrieve session info. - - Returns: - dict: Keys are 'login', 'registration', and 'password_reset'; - values are the JSON-serialized form descriptions. - - """ - - return { - 'password_reset': get_password_reset_form().to_json(), - 'login': get_login_session_form(request).to_json(), - 'registration': RegistrationFormFactory().get_registration_form(request).to_json() - } - - -def _get_extended_profile_fields(): - """Retrieve the extended profile fields from site configuration to be shown on the - Account Settings page - - Returns: - A list of dicts. Each dict corresponds to a single field. The keys per field are: - "field_name" : name of the field stored in user_profile.meta - "field_label" : The label of the field. - "field_type" : TextField or ListField - "field_options": a list of tuples for options in the dropdown in case of ListField - """ - - extended_profile_fields = [] - fields_already_showing = ['username', 'name', 'email', 'pref-lang', 'country', 'time_zone', 'level_of_education', - 'gender', 'year_of_birth', 'language_proficiencies', 'social_links'] - - field_labels_map = { - "first_name": _(u"First Name"), - "last_name": _(u"Last Name"), - "city": _(u"City"), - "state": _(u"State/Province/Region"), - "company": _(u"Company"), - "title": _(u"Title"), - "job_title": _(u"Job Title"), - "mailing_address": _(u"Mailing address"), - "goals": _(u"Tell us why you're interested in {platform_name}").format( - platform_name=configuration_helpers.get_value("PLATFORM_NAME", settings.PLATFORM_NAME) - ), - "profession": _(u"Profession"), - "specialty": _(u"Specialty") - } - - extended_profile_field_names = configuration_helpers.get_value('extended_profile_fields', []) - for field_to_exclude in fields_already_showing: - if field_to_exclude in extended_profile_field_names: - extended_profile_field_names.remove(field_to_exclude) - - extended_profile_field_options = configuration_helpers.get_value('EXTRA_FIELD_OPTIONS', []) - extended_profile_field_option_tuples = {} - for field in extended_profile_field_options.keys(): - field_options = extended_profile_field_options[field] - extended_profile_field_option_tuples[field] = [(option.lower(), option) for option in field_options] - - for field in extended_profile_field_names: - field_dict = { - "field_name": field, - "field_label": field_labels_map.get(field, field), - } - - field_options = extended_profile_field_option_tuples.get(field) - if field_options: - field_dict["field_type"] = "ListField" - field_dict["field_options"] = field_options - else: - field_dict["field_type"] = "TextField" - extended_profile_fields.append(field_dict) - - return extended_profile_fields - - -def _external_auth_intercept(request, mode): - """Allow external auth to intercept a login/registration request. - - Arguments: - request (Request): The original request. - mode (str): Either "login" or "register" - - Returns: - Response or None - - """ - if mode == "login": - return external_auth_login(request) - elif mode == "register": - return external_auth_register(request) - - -def get_user_orders(user): - """Given a user, get the detail of all the orders from the Ecommerce service. - - Args: - user (User): The user to authenticate as when requesting ecommerce. - - Returns: - list of dict, representing orders returned by the Ecommerce service. - """ - no_data = [] - user_orders = [] - commerce_configuration = CommerceConfiguration.current() - user_query = {'username': user.username} - - use_cache = commerce_configuration.is_cache_enabled - cache_key = commerce_configuration.CACHE_KEY + '.' + str(user.id) if use_cache else None - api = ecommerce_api_client(user) - commerce_user_orders = get_edx_api_data( - commerce_configuration, 'orders', api=api, querystring=user_query, cache_key=cache_key - ) - - for order in commerce_user_orders: - if order['status'].lower() == 'complete': - date_placed = datetime.strptime(order['date_placed'], "%Y-%m-%dT%H:%M:%SZ") - order_data = { - 'number': order['number'], - 'price': order['total_excl_tax'], - 'order_date': strftime_localized(date_placed, 'SHORT_DATE'), - 'receipt_url': EcommerceService().get_receipt_page_url(order['number']), - 'lines': order['lines'], - } - user_orders.append(order_data) - - return user_orders - - -@login_required -@require_http_methods(['GET']) -def account_settings(request): - """Render the current user's account settings page. - - Args: - request (HttpRequest) - - Returns: - HttpResponse: 200 if the page was sent successfully - HttpResponse: 302 if not logged in (redirect to login page) - HttpResponse: 405 if using an unsupported HTTP method - - Example usage: - - GET /account/settings - - """ - context = account_settings_context(request) - return render_to_response('student_account/account_settings.html', context) - - -@login_required -@require_http_methods(['GET']) -def finish_auth(request): # pylint: disable=unused-argument - """ Following logistration (1st or 3rd party), handle any special query string params. - - See FinishAuthView.js for details on the query string params. - - e.g. auto-enroll the user in a course, set email opt-in preference. - - This view just displays a "Please wait" message while AJAX calls are made to enroll the - user in the course etc. This view is only used if a parameter like "course_id" is present - during login/registration/third_party_auth. Otherwise, there is no need for it. - - Ideally this view will finish and redirect to the next step before the user even sees it. - - Args: - request (HttpRequest) - - Returns: - HttpResponse: 200 if the page was sent successfully - HttpResponse: 302 if not logged in (redirect to login page) - HttpResponse: 405 if using an unsupported HTTP method - - Example usage: - - GET /account/finish_auth/?course_id=course-v1:blah&enrollment_action=enroll - - """ - return render_to_response('student_account/finish_auth.html', { - 'disable_courseware_js': True, - 'disable_footer': True, - }) - - -def account_settings_context(request): - """ Context for the account settings page. - - Args: - request: The request object. - - Returns: - dict - - """ - user = request.user - - year_of_birth_options = [(unicode(year), unicode(year)) for year in UserProfile.VALID_YEARS] - try: - user_orders = get_user_orders(user) - except: # pylint: disable=bare-except - log.exception('Error fetching order history from Otto.') - # Return empty order list as account settings page expect a list and - # it will be broken if exception raised - user_orders = [] - - context = { - 'auth': {}, - 'duplicate_provider': None, - 'nav_hidden': True, - 'fields': { - 'country': { - 'options': list(countries), - }, 'gender': { - 'options': [(choice[0], _(choice[1])) for choice in UserProfile.GENDER_CHOICES], - }, 'language': { - 'options': released_languages(), - }, 'level_of_education': { - 'options': [(choice[0], _(choice[1])) for choice in UserProfile.LEVEL_OF_EDUCATION_CHOICES], - }, 'password': { - 'url': reverse('password_reset'), - }, 'year_of_birth': { - 'options': year_of_birth_options, - }, 'preferred_language': { - 'options': all_languages(), - }, 'time_zone': { - 'options': TIME_ZONE_CHOICES, - } - }, - 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), - 'password_reset_support_link': configuration_helpers.get_value( - 'PASSWORD_RESET_SUPPORT_LINK', settings.PASSWORD_RESET_SUPPORT_LINK - ) or settings.SUPPORT_SITE_LINK, - 'user_accounts_api_url': reverse("accounts_api", kwargs={'username': user.username}), - 'user_preferences_api_url': reverse('preferences_api', kwargs={'username': user.username}), - 'disable_courseware_js': True, - 'show_program_listing': ProgramsApiConfig.is_enabled(), - 'show_dashboard_tabs': True, - 'order_history': user_orders, - 'enable_account_deletion': configuration_helpers.get_value( - 'ENABLE_ACCOUNT_DELETION', settings.FEATURES.get('ENABLE_ACCOUNT_DELETION', False) - ), - 'extended_profile_fields': _get_extended_profile_fields(), - } - - enterprise_customer = get_enterprise_customer_for_learner(site=request.site, user=request.user) - update_account_settings_context_for_enterprise(context, enterprise_customer) - - if third_party_auth.is_enabled(): - # If the account on the third party provider is already connected with another edX account, - # we display a message to the user. - context['duplicate_provider'] = pipeline.get_duplicate_provider(messages.get_messages(request)) - - auth_states = pipeline.get_provider_user_states(user) - - context['auth']['providers'] = [{ - 'id': state.provider.provider_id, - 'name': state.provider.name, # The name of the provider e.g. Facebook - 'connected': state.has_account, # Whether the user's edX account is connected with the provider. - # If the user is not connected, they should be directed to this page to authenticate - # with the particular provider, as long as the provider supports initiating a login. - 'connect_url': pipeline.get_login_url( - state.provider.provider_id, - pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS, - # The url the user should be directed to after the auth process has completed. - redirect_url=reverse('account_settings'), - ), - 'accepts_logins': state.provider.accepts_logins, - # If the user is connected, sending a POST request to this url removes the connection - # information for this provider from their edX account. - 'disconnect_url': pipeline.get_disconnect_url(state.provider.provider_id, state.association_id), - # We only want to include providers if they are either currently available to be logged - # in with, or if the user is already authenticated with them. - } for state in auth_states if state.provider.display_for_login or state.has_account] - - return context diff --git a/lms/urls.py b/lms/urls.py index 0f184ae16619..1021db546ac6 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -50,7 +50,6 @@ from static_template_view import views as static_template_view_views from staticbook import views as staticbook_views from student import views as student_views -from student_account import views as student_account_views from track import views as track_views from util import views as util_views @@ -142,23 +141,6 @@ url(r'^api/experiments/', include('experiments.urls', namespace='api_experiments')), ] -# TODO: This needs to move to a separate urls.py once the student_account and -# student views below find a home together -if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'): - # Backwards compatibility with old URL structure, but serve the new views - urlpatterns += [ - url(r'^login$', student_account_views.login_and_registration_form, - {'initial_mode': 'login'}, name='signin_user'), - url(r'^register$', student_account_views.login_and_registration_form, - {'initial_mode': 'register'}, name='register_user'), - ] -else: - # Serve the old views - urlpatterns += [ - url(r'^login$', student_views.signin_user, name='signin_user'), - url(r'^register$', student_views.register_user, name='register_user'), - ] - if settings.FEATURES.get('ENABLE_MOBILE_REST_API'): urlpatterns += [ url(r'^api/mobile/v0.5/', include('mobile_api.urls')), @@ -608,12 +590,6 @@ name='lti_rest_endpoints', ), - # Student account - url( - r'^account/', - include('student_account.urls') - ), - # Student Notes url( r'^courses/{}/edxnotes/'.format( @@ -958,9 +934,6 @@ urlpatterns += [ url(r'', include('third_party_auth.urls')), url(r'api/third_party_auth/', include('third_party_auth.api.urls')), - # NOTE: The following login_oauth_token endpoint is DEPRECATED. - # Please use the exchange_access_token endpoint instead. - url(r'^login_oauth_token/(?P[^/]+)/$', student_views.login_oauth_token), ] # Enterprise diff --git a/openedx/core/djangoapps/external_auth/tests/test_shib.py b/openedx/core/djangoapps/external_auth/tests/test_shib.py index debcf79b6b9d..f4fc5b9d0940 100644 --- a/openedx/core/djangoapps/external_auth/tests/test_shib.py +++ b/openedx/core/djangoapps/external_auth/tests/test_shib.py @@ -316,7 +316,7 @@ def test_registration_form_submit(self, identity): 'terms_of_service': u'true', 'honor_code': u'true'} - with patch('student.views.management.AUDIT_LOG') as mock_audit_log: + with patch('openedx.core.djangoapps.user_authn.views.register.AUDIT_LOG') as mock_audit_log: self.client.post('/create_account', data=postvars) mail = identity.get('mail') diff --git a/openedx/core/djangoapps/external_auth/views.py b/openedx/core/djangoapps/external_auth/views.py index a57ee08af6a9..0d01a07d8b20 100644 --- a/openedx/core/djangoapps/external_auth/views.py +++ b/openedx/core/djangoapps/external_auth/views.py @@ -282,7 +282,9 @@ def _signup(request, eamap, retfun=None): retfun is a function to execute for the return value, if immediate signup is used. That allows @ssl_login_shortcut() to work. """ - # save this for use by student.views.create_account + from openedx.core.djangoapps.user_authn.views.deprecated import create_account, register_user + + # save this for use by create_account request.session['ExternalAuthMap'] = eamap if settings.FEATURES.get('AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP', ''): @@ -294,7 +296,7 @@ def _signup(request, eamap, retfun=None): honor_code=u'true', terms_of_service=u'true') log.info(u'doing immediate signup for %s, params=%s', username, post_vars) - student.views.create_account(request, post_vars) + create_account(request, post_vars) # should check return content for successful completion before if retfun is not None: return retfun() @@ -335,7 +337,7 @@ def _signup(request, eamap, retfun=None): log.info(u'EXTAUTH: Doing signup for %s', eamap.external_id) - return student.views.register_user(request, extra_context=context) + return register_user(request, extra_context=context) # ----------------------------------------------------------------------------- diff --git a/openedx/core/djangoapps/oauth_dispatch/api.py b/openedx/core/djangoapps/oauth_dispatch/api.py new file mode 100644 index 000000000000..33c635a304bf --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/api.py @@ -0,0 +1,15 @@ +""" OAuth related Python apis. """ +from oauth2_provider.models import AccessToken as dot_access_token +from oauth2_provider.models import RefreshToken as dot_refresh_token +from provider.oauth2.models import AccessToken as dop_access_token +from provider.oauth2.models import RefreshToken as dop_refresh_token + + +def destroy_oauth_tokens(user): + """ + Destroys ALL OAuth access and refresh tokens for the given user. + """ + dop_access_token.objects.filter(user=user.id).delete() + dop_refresh_token.objects.filter(user=user.id).delete() + dot_access_token.objects.filter(user=user.id).delete() + dot_refresh_token.objects.filter(user=user.id).delete() diff --git a/openedx/core/djangoapps/user_api/accounts/settings_views.py b/openedx/core/djangoapps/user_api/accounts/settings_views.py new file mode 100644 index 000000000000..cabaf2257127 --- /dev/null +++ b/openedx/core/djangoapps/user_api/accounts/settings_views.py @@ -0,0 +1,241 @@ +""" Views related to Account Settings. """ + +from datetime import datetime +import logging +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.utils.translation import ugettext as _ +from django.urls import reverse +from django.views.decorators.http import require_http_methods +from django_countries import countries + +from edxmako.shortcuts import render_to_response + +from lms.djangoapps.commerce.models import CommerceConfiguration +from lms.djangoapps.commerce.utils import EcommerceService +from openedx.core.djangoapps.commerce.utils import ecommerce_api_client +from openedx.core.djangoapps.lang_pref.api import all_languages, released_languages +from openedx.core.djangoapps.programs.models import ProgramsApiConfig +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +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.features.enterprise_support.api import get_enterprise_customer_for_learner +from openedx.features.enterprise_support.utils import update_account_settings_context_for_enterprise +from student.models import UserProfile +import third_party_auth +from third_party_auth import pipeline +from util.date_utils import strftime_localized + + +log = logging.getLogger(__name__) + + +@login_required +@require_http_methods(['GET']) +def account_settings(request): + """Render the current user's account settings page. + + Args: + request (HttpRequest) + + Returns: + HttpResponse: 200 if the page was sent successfully + HttpResponse: 302 if not logged in (redirect to login page) + HttpResponse: 405 if using an unsupported HTTP method + + Example usage: + + GET /account/settings + + """ + context = account_settings_context(request) + return render_to_response('student_account/account_settings.html', context) + + +def account_settings_context(request): + """ Context for the account settings page. + + Args: + request: The request object. + + Returns: + dict + + """ + user = request.user + + year_of_birth_options = [(unicode(year), unicode(year)) for year in UserProfile.VALID_YEARS] + try: + user_orders = get_user_orders(user) + except: # pylint: disable=bare-except + log.exception('Error fetching order history from Otto.') + # Return empty order list as account settings page expect a list and + # it will be broken if exception raised + user_orders = [] + + context = { + 'auth': {}, + 'duplicate_provider': None, + 'nav_hidden': True, + 'fields': { + 'country': { + 'options': list(countries), + }, 'gender': { + 'options': [(choice[0], _(choice[1])) for choice in UserProfile.GENDER_CHOICES], + }, 'language': { + 'options': released_languages(), + }, 'level_of_education': { + 'options': [(choice[0], _(choice[1])) for choice in UserProfile.LEVEL_OF_EDUCATION_CHOICES], + }, 'password': { + 'url': reverse('password_reset'), + }, 'year_of_birth': { + 'options': year_of_birth_options, + }, 'preferred_language': { + 'options': all_languages(), + }, 'time_zone': { + 'options': TIME_ZONE_CHOICES, + } + }, + 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), + 'password_reset_support_link': configuration_helpers.get_value( + 'PASSWORD_RESET_SUPPORT_LINK', settings.PASSWORD_RESET_SUPPORT_LINK + ) or settings.SUPPORT_SITE_LINK, + 'user_accounts_api_url': reverse("accounts_api", kwargs={'username': user.username}), + 'user_preferences_api_url': reverse('preferences_api', kwargs={'username': user.username}), + 'disable_courseware_js': True, + 'show_program_listing': ProgramsApiConfig.is_enabled(), + 'show_dashboard_tabs': True, + 'order_history': user_orders, + 'enable_account_deletion': configuration_helpers.get_value( + 'ENABLE_ACCOUNT_DELETION', settings.FEATURES.get('ENABLE_ACCOUNT_DELETION', False) + ), + 'extended_profile_fields': _get_extended_profile_fields(), + } + + enterprise_customer = get_enterprise_customer_for_learner(site=request.site, user=request.user) + update_account_settings_context_for_enterprise(context, enterprise_customer) + + if third_party_auth.is_enabled(): + # If the account on the third party provider is already connected with another edX account, + # we display a message to the user. + context['duplicate_provider'] = pipeline.get_duplicate_provider(messages.get_messages(request)) + + auth_states = pipeline.get_provider_user_states(user) + + context['auth']['providers'] = [{ + 'id': state.provider.provider_id, + 'name': state.provider.name, # The name of the provider e.g. Facebook + 'connected': state.has_account, # Whether the user's edX account is connected with the provider. + # If the user is not connected, they should be directed to this page to authenticate + # with the particular provider, as long as the provider supports initiating a login. + 'connect_url': pipeline.get_login_url( + state.provider.provider_id, + pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS, + # The url the user should be directed to after the auth process has completed. + redirect_url=reverse('account_settings'), + ), + 'accepts_logins': state.provider.accepts_logins, + # If the user is connected, sending a POST request to this url removes the connection + # information for this provider from their edX account. + 'disconnect_url': pipeline.get_disconnect_url(state.provider.provider_id, state.association_id), + # We only want to include providers if they are either currently available to be logged + # in with, or if the user is already authenticated with them. + } for state in auth_states if state.provider.display_for_login or state.has_account] + + return context + + +def get_user_orders(user): + """Given a user, get the detail of all the orders from the Ecommerce service. + + Args: + user (User): The user to authenticate as when requesting ecommerce. + + Returns: + list of dict, representing orders returned by the Ecommerce service. + """ + user_orders = [] + commerce_configuration = CommerceConfiguration.current() + user_query = {'username': user.username} + + use_cache = commerce_configuration.is_cache_enabled + cache_key = commerce_configuration.CACHE_KEY + '.' + str(user.id) if use_cache else None + api = ecommerce_api_client(user) + commerce_user_orders = get_edx_api_data( + commerce_configuration, 'orders', api=api, querystring=user_query, cache_key=cache_key + ) + + for order in commerce_user_orders: + if order['status'].lower() == 'complete': + date_placed = datetime.strptime(order['date_placed'], "%Y-%m-%dT%H:%M:%SZ") + order_data = { + 'number': order['number'], + 'price': order['total_excl_tax'], + 'order_date': strftime_localized(date_placed, 'SHORT_DATE'), + 'receipt_url': EcommerceService().get_receipt_page_url(order['number']), + 'lines': order['lines'], + } + user_orders.append(order_data) + + return user_orders + + +def _get_extended_profile_fields(): + """Retrieve the extended profile fields from site configuration to be shown on the + Account Settings page + + Returns: + A list of dicts. Each dict corresponds to a single field. The keys per field are: + "field_name" : name of the field stored in user_profile.meta + "field_label" : The label of the field. + "field_type" : TextField or ListField + "field_options": a list of tuples for options in the dropdown in case of ListField + """ + + extended_profile_fields = [] + fields_already_showing = ['username', 'name', 'email', 'pref-lang', 'country', 'time_zone', 'level_of_education', + 'gender', 'year_of_birth', 'language_proficiencies', 'social_links'] + + field_labels_map = { + "first_name": _(u"First Name"), + "last_name": _(u"Last Name"), + "city": _(u"City"), + "state": _(u"State/Province/Region"), + "company": _(u"Company"), + "title": _(u"Title"), + "job_title": _(u"Job Title"), + "mailing_address": _(u"Mailing address"), + "goals": _(u"Tell us why you're interested in {platform_name}").format( + platform_name=configuration_helpers.get_value("PLATFORM_NAME", settings.PLATFORM_NAME) + ), + "profession": _(u"Profession"), + "specialty": _(u"Specialty") + } + + extended_profile_field_names = configuration_helpers.get_value('extended_profile_fields', []) + for field_to_exclude in fields_already_showing: + if field_to_exclude in extended_profile_field_names: + extended_profile_field_names.remove(field_to_exclude) + + extended_profile_field_options = configuration_helpers.get_value('EXTRA_FIELD_OPTIONS', []) + extended_profile_field_option_tuples = {} + for field in extended_profile_field_options.keys(): + field_options = extended_profile_field_options[field] + extended_profile_field_option_tuples[field] = [(option.lower(), option) for option in field_options] + + for field in extended_profile_field_names: + field_dict = { + "field_name": field, + "field_label": field_labels_map.get(field, field), + } + + field_options = extended_profile_field_option_tuples.get(field) + if field_options: + field_dict["field_type"] = "ListField" + field_dict["field_options"] = field_options + else: + field_dict["field_type"] = "TextField" + extended_profile_fields.append(field_dict) + + return extended_profile_fields diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py new file mode 100644 index 000000000000..072fceb3209c --- /dev/null +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py @@ -0,0 +1,229 @@ +""" Tests for views related to account settings. """ +# -*- coding: utf-8 -*- +import mock +from django.conf import settings +from django.contrib import messages +from django.contrib.messages.middleware import MessageMiddleware +from django.urls import reverse +from django.http import HttpRequest +from django.test import TestCase +from edx_rest_api_client import exceptions + +from lms.djangoapps.commerce.models import CommerceConfiguration +from lms.djangoapps.commerce.tests import factories +from lms.djangoapps.commerce.tests.mocks import mock_get_orders +from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin +from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms +from openedx.core.djangoapps.user_api.accounts.settings_views import account_settings_context, get_user_orders +from student.tests.factories import UserFactory +from third_party_auth.tests.testutil import ThirdPartyAuthTestMixin + + +@skip_unless_lms +class AccountSettingsViewTest(ThirdPartyAuthTestMixin, TestCase, ProgramsApiConfigMixin): + """ Tests for the account settings view. """ + + USERNAME = 'student' + PASSWORD = 'password' + FIELDS = [ + 'country', + 'gender', + 'language', + 'level_of_education', + 'password', + 'year_of_birth', + 'preferred_language', + 'time_zone', + ] + + @mock.patch("django.conf.settings.MESSAGE_STORAGE", 'django.contrib.messages.storage.cookie.CookieStorage') + def setUp(self): # pylint: disable=arguments-differ + super(AccountSettingsViewTest, self).setUp() + self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD) + CommerceConfiguration.objects.create(cache_ttl=10, enabled=True) + self.client.login(username=self.USERNAME, password=self.PASSWORD) + + self.request = HttpRequest() + self.request.user = self.user + + # For these tests, two third party auth providers are enabled by default: + self.configure_google_provider(enabled=True, visible=True) + self.configure_facebook_provider(enabled=True, visible=True) + + # Python-social saves auth failure notifcations in Django messages. + # See pipeline.get_duplicate_provider() for details. + self.request.COOKIES = {} + MessageMiddleware().process_request(self.request) + messages.error(self.request, 'Facebook is already in use.', extra_tags='Auth facebook') + + @mock.patch('openedx.features.enterprise_support.api.get_enterprise_customer_for_learner') + def test_context(self, mock_get_enterprise_customer_for_learner): + self.request.site = SiteFactory.create() + mock_get_enterprise_customer_for_learner.return_value = {} + context = account_settings_context(self.request) + + user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username}) + self.assertEqual(context['user_accounts_api_url'], user_accounts_api_url) + + user_preferences_api_url = reverse('preferences_api', kwargs={'username': self.user.username}) + self.assertEqual(context['user_preferences_api_url'], user_preferences_api_url) + + for attribute in self.FIELDS: + self.assertIn(attribute, context['fields']) + + self.assertEqual( + context['user_accounts_api_url'], reverse("accounts_api", kwargs={'username': self.user.username}) + ) + self.assertEqual( + context['user_preferences_api_url'], reverse('preferences_api', kwargs={'username': self.user.username}) + ) + + self.assertEqual(context['duplicate_provider'], 'facebook') + self.assertEqual(context['auth']['providers'][0]['name'], 'Facebook') + self.assertEqual(context['auth']['providers'][1]['name'], 'Google') + + self.assertEqual(context['sync_learner_profile_data'], False) + self.assertEqual(context['edx_support_url'], settings.SUPPORT_SITE_LINK) + self.assertEqual(context['enterprise_name'], None) + self.assertEqual( + context['enterprise_readonly_account_fields'], {'fields': settings.ENTERPRISE_READONLY_ACCOUNT_FIELDS} + ) + + @mock.patch('openedx.core.djangoapps.user_api.accounts.settings_views.get_enterprise_customer_for_learner') + @mock.patch('openedx.features.enterprise_support.utils.third_party_auth.provider.Registry.get') + def test_context_for_enterprise_learner( + self, mock_get_auth_provider, mock_get_enterprise_customer_for_learner + ): + dummy_enterprise_customer = { + 'uuid': 'real-ent-uuid', + 'name': 'Dummy Enterprise', + 'identity_provider': 'saml-ubc' + } + mock_get_enterprise_customer_for_learner.return_value = dummy_enterprise_customer + self.request.site = SiteFactory.create() + mock_get_auth_provider.return_value.sync_learner_profile_data = True + context = account_settings_context(self.request) + + user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username}) + self.assertEqual(context['user_accounts_api_url'], user_accounts_api_url) + + user_preferences_api_url = reverse('preferences_api', kwargs={'username': self.user.username}) + self.assertEqual(context['user_preferences_api_url'], user_preferences_api_url) + + for attribute in self.FIELDS: + self.assertIn(attribute, context['fields']) + + self.assertEqual( + context['user_accounts_api_url'], reverse("accounts_api", kwargs={'username': self.user.username}) + ) + self.assertEqual( + context['user_preferences_api_url'], reverse('preferences_api', kwargs={'username': self.user.username}) + ) + + self.assertEqual(context['duplicate_provider'], 'facebook') + self.assertEqual(context['auth']['providers'][0]['name'], 'Facebook') + self.assertEqual(context['auth']['providers'][1]['name'], 'Google') + + self.assertEqual( + context['sync_learner_profile_data'], mock_get_auth_provider.return_value.sync_learner_profile_data + ) + self.assertEqual(context['edx_support_url'], settings.SUPPORT_SITE_LINK) + self.assertEqual(context['enterprise_name'], dummy_enterprise_customer['name']) + self.assertEqual( + context['enterprise_readonly_account_fields'], {'fields': settings.ENTERPRISE_READONLY_ACCOUNT_FIELDS} + ) + + def test_view(self): + """ + Test that all fields are visible + """ + view_path = reverse('account_settings') + response = self.client.get(path=view_path) + + for attribute in self.FIELDS: + self.assertIn(attribute, response.content) + + def test_header_with_programs_listing_enabled(self): + """ + Verify that tabs header will be shown while program listing is enabled. + """ + self.create_programs_config() + view_path = reverse('account_settings') + response = self.client.get(path=view_path) + + self.assertContains(response, 'global-header') + + def test_header_with_programs_listing_disabled(self): + """ + Verify that nav header will be shown while program listing is disabled. + """ + self.create_programs_config(enabled=False) + view_path = reverse('account_settings') + response = self.client.get(path=view_path) + + self.assertContains(response, 'global-header') + + def test_commerce_order_detail(self): + """ + Verify that get_user_orders returns the correct order data. + """ + with mock_get_orders(): + order_detail = get_user_orders(self.user) + + for i, order in enumerate(mock_get_orders.default_response['results']): + expected = { + 'number': order['number'], + 'price': order['total_excl_tax'], + 'order_date': 'Jan 01, 2016', + 'receipt_url': '/checkout/receipt/?order_number=' + order['number'], + 'lines': order['lines'], + } + self.assertEqual(order_detail[i], expected) + + def test_commerce_order_detail_exception(self): + with mock_get_orders(exception=exceptions.HttpNotFoundError): + order_detail = get_user_orders(self.user) + + self.assertEqual(order_detail, []) + + def test_incomplete_order_detail(self): + response = { + 'results': [ + factories.OrderFactory( + status='Incomplete', + lines=[ + factories.OrderLineFactory( + product=factories.ProductFactory(attribute_values=[factories.ProductAttributeFactory()]) + ) + ] + ) + ] + } + with mock_get_orders(response=response): + order_detail = get_user_orders(self.user) + + self.assertEqual(order_detail, []) + + def test_order_history_with_no_product(self): + response = { + 'results': [ + factories.OrderFactory( + lines=[ + factories.OrderLineFactory( + product=None + ), + factories.OrderLineFactory( + product=factories.ProductFactory(attribute_values=[factories.ProductAttributeFactory( + name='certificate_type', + value='verified' + )]) + ) + ] + ) + ] + } + with mock_get_orders(response=response): + order_detail = get_user_orders(self.user) + + self.assertEqual(len(order_detail), 1) diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 81ace7529ddc..da924c29a87c 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -33,6 +33,7 @@ from wiki.models.pluginbase import RevisionPluginRevision from entitlements.models import CourseEntitlement +from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError from openedx.core.djangoapps.ace_common.template_context import get_base_template_context from openedx.core.djangoapps.api_admin.models import ApiAccessRequest from openedx.core.djangoapps.credit.models import CreditRequirementStatus, CreditRequest @@ -51,6 +52,7 @@ PasswordHistory, PendingNameChange, CourseEnrollmentAllowed, + LoginFailures, PendingEmailChange, Registration, User, @@ -60,8 +62,6 @@ get_retired_username_by_username, is_username_retired ) -from student.views.login import AuthFailedError, LoginFailures - from ..errors import AccountUpdateError, AccountValidationError, UserNotAuthorized, UserNotFound from ..models import ( RetirementState, diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index 1eb7be2f0452..e505d6154d14 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -9,9 +9,6 @@ from django.views.decorators.csrf import csrf_exempt, csrf_protect, ensure_csrf_cookie from django.views.decorators.debug import sensitive_post_parameters from django_filters.rest_framework import DjangoFilterBackend -from opaque_keys import InvalidKeyError -from opaque_keys.edx import locator -from opaque_keys.edx.keys import CourseKey from rest_framework import authentication, generics, status, viewsets from rest_framework.exceptions import ParseError from rest_framework.views import APIView @@ -20,6 +17,9 @@ import accounts from django_comment_common.models import Role +from opaque_keys import InvalidKeyError +from opaque_keys.edx import locator +from opaque_keys.edx.keys import CourseKey from openedx.core.djangoapps.user_api.accounts.api import check_account_exists from openedx.core.djangoapps.user_api.api import ( RegistrationFormFactory, @@ -30,10 +30,11 @@ from openedx.core.djangoapps.user_api.models import UserPreference from openedx.core.djangoapps.user_api.preferences.api import get_country_time_zones, update_email_opt_in from openedx.core.djangoapps.user_api.serializers import CountryTimeZoneSerializer, UserPreferenceSerializer, UserSerializer +from openedx.core.djangoapps.user_authn.cookies import set_logged_in_cookies +from openedx.core.djangoapps.user_authn.views.register import create_account_with_params from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser from openedx.core.lib.api.permissions import ApiKeyHeaderPermission -from student.cookies import set_logged_in_cookies -from student.views import AccountValidationError, create_account_with_params +from student.helpers import AccountValidationError from util.json_request import JsonResponse @@ -82,7 +83,7 @@ def post(self, request): """ # For the initial implementation, shim the existing login view # from the student Django app. - from student.views import login_user + from openedx.core.djangoapps.user_authn.views.login import login_user return shim_student_view(login_user, check_logged_in=True)(request) @method_decorator(sensitive_post_parameters("password")) diff --git a/lms/djangoapps/student_account/__init__.py b/openedx/core/djangoapps/user_authn/__init__.py similarity index 100% rename from lms/djangoapps/student_account/__init__.py rename to openedx/core/djangoapps/user_authn/__init__.py diff --git a/openedx/core/djangoapps/user_authn/apps.py b/openedx/core/djangoapps/user_authn/apps.py new file mode 100644 index 000000000000..128225801a21 --- /dev/null +++ b/openedx/core/djangoapps/user_authn/apps.py @@ -0,0 +1,24 @@ +""" +User Authentication Configuration +""" + +from django.apps import AppConfig + +from openedx.core.djangoapps.plugins.constants import ProjectType, PluginURLs + + +class UserAuthnConfig(AppConfig): + """ + Application Configuration for User Authentication. + """ + name = u'openedx.core.djangoapps.user_authn' + + plugin_app = { + PluginURLs.CONFIG: { + ProjectType.LMS: { + PluginURLs.NAMESPACE: u'', + PluginURLs.REGEX: u'', + PluginURLs.RELATIVE_PATH: u'urls', + }, + }, + } diff --git a/common/djangoapps/student/cookies.py b/openedx/core/djangoapps/user_authn/cookies.py similarity index 100% rename from common/djangoapps/student/cookies.py rename to openedx/core/djangoapps/user_authn/cookies.py diff --git a/openedx/core/djangoapps/user_authn/exceptions.py b/openedx/core/djangoapps/user_authn/exceptions.py new file mode 100644 index 000000000000..d86b5f8d515a --- /dev/null +++ b/openedx/core/djangoapps/user_authn/exceptions.py @@ -0,0 +1,22 @@ +""" User Authn related Exceptions. """ + + +class AuthFailedError(Exception): + """ + This is a helper for the login view, allowing the various sub-methods to early out with an appropriate failure + message. + """ + def __init__(self, value=None, redirect=None, redirect_url=None): + super(AuthFailedError, self).__init__() + self.value = value + self.redirect = redirect + self.redirect_url = redirect_url + + def get_response(self): + """ Returns a dict representation of the error. """ + resp = {'success': False} + for attr in ('value', 'redirect', 'redirect_url'): + if self.__getattribute__(attr): + resp[attr] = self.__getattribute__(attr) + + return resp diff --git a/lms/djangoapps/student_account/test/__init__.py b/openedx/core/djangoapps/user_authn/tests/__init__.py similarity index 100% rename from lms/djangoapps/student_account/test/__init__.py rename to openedx/core/djangoapps/user_authn/tests/__init__.py diff --git a/common/djangoapps/student/tests/test_cookies.py b/openedx/core/djangoapps/user_authn/tests/test_cookies.py similarity index 96% rename from common/djangoapps/student/tests/test_cookies.py rename to openedx/core/djangoapps/user_authn/tests/test_cookies.py index 0479fa5fbe57..2660202bf887 100644 --- a/common/djangoapps/student/tests/test_cookies.py +++ b/openedx/core/djangoapps/user_authn/tests/test_cookies.py @@ -6,8 +6,8 @@ from django.urls import reverse from django.test import RequestFactory +from openedx.core.djangoapps.user_authn.cookies import get_user_info_cookie_data from openedx.core.djangoapps.user_api.accounts.utils import retrieve_last_sitewide_block_completed -from student.cookies import get_user_info_cookie_data from student.models import CourseEnrollment from student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase diff --git a/openedx/core/djangoapps/user_authn/urls.py b/openedx/core/djangoapps/user_authn/urls.py new file mode 100644 index 000000000000..7462a71b095f --- /dev/null +++ b/openedx/core/djangoapps/user_authn/urls.py @@ -0,0 +1,32 @@ +""" URLs for User Authentication """ +from django.conf import settings +from django.conf.urls import include, url + +from openedx.core.djangoapps.user_api.accounts import settings_views +from .views import login_form, login, deprecated + + +urlpatterns = [ + # TODO this should really be declared in the user_api app + url(r'^account/settings$', settings_views.account_settings, name='account_settings'), + + # TODO move contents of urls_common here once CMS no longer has its own login + url(r'', include('openedx.core.djangoapps.user_authn.urls_common')), + url(r'^account/finish_auth$', login.finish_auth, name='finish_auth'), +] + + +if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'): + # Backwards compatibility with old URL structure, but serve the new views + urlpatterns += [ + url(r'^login$', login_form.login_and_registration_form, + {'initial_mode': 'login'}, name='signin_user'), + url(r'^register$', login_form.login_and_registration_form, + {'initial_mode': 'register'}, name='register_user'), + ] +else: + # Serve the old views + urlpatterns += [ + url(r'^login$', deprecated.signin_user, name='signin_user'), + url(r'^register$', deprecated.register_user, name='register_user'), + ] diff --git a/openedx/core/djangoapps/user_authn/urls_common.py b/openedx/core/djangoapps/user_authn/urls_common.py new file mode 100644 index 000000000000..d53633a4c5a7 --- /dev/null +++ b/openedx/core/djangoapps/user_authn/urls_common.py @@ -0,0 +1,29 @@ +""" +Common URLs for User Authentication + +Note: The split between urls.py and urls_common.py is hopefully temporary. +For now, this is needed because of difference in CMS and LMS that have +not yet been cleaned up. + +""" +from django.conf import settings +from django.conf.urls import url + +from .views import auto_auth, login, logout, deprecated + + +urlpatterns = [ + url(r'^create_account$', deprecated.create_account, name='create_account'), + url(r'^login_post$', login.login_user, name='login_post'), + url(r'^login_ajax$', login.login_user, name="login"), + url(r'^login_ajax/(?P[^/]*)$', login.login_user), + + url(r'^logout$', logout.LogoutView.as_view(), name='logout'), +] + + +# enable automatic login +if settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING'): + urlpatterns += [ + url(r'^auto_auth$', auto_auth.auto_auth), + ] diff --git a/openedx/core/djangoapps/user_authn/views/__init__.py b/openedx/core/djangoapps/user_authn/views/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/user_authn/views/auto_auth.py b/openedx/core/djangoapps/user_authn/views/auto_auth.py new file mode 100644 index 000000000000..3ea5f719a6ab --- /dev/null +++ b/openedx/core/djangoapps/user_authn/views/auto_auth.py @@ -0,0 +1,202 @@ +""" Views related to auto auth. """ +import datetime +import uuid + +from django.conf import settings +from django.contrib.auth import login as django_login +from django.contrib.auth.models import User +from django.core.exceptions import PermissionDenied +from django.urls import NoReverseMatch, reverse +from django.core.validators import ValidationError +from django.http import HttpResponseForbidden +from django.shortcuts import redirect +from django.template.context_processors import csrf +from django.utils.translation import ugettext as _ +from django_comment_common.models import assign_role + +from opaque_keys.edx.locator import CourseLocator +from openedx.core.djangoapps.user_api.accounts.utils import generate_password +from openedx.features.course_experience import course_home_url_name +from student.forms import AccountCreationForm +from student.helpers import ( + AccountValidationError, + create_or_set_user_attribute_created_on_site, +) +from student.models import ( + CourseAccessRole, + CourseEnrollment, + Registration, + UserProfile, + anonymous_id_for_user, + create_comments_service_user +) +from student.helpers import authenticate_new_user, do_create_account +from util.json_request import JsonResponse + + +def auto_auth(request): # pylint: disable=too-many-statements + """ + Create or configure a user account, then log in as that user. + + Enabled only when + settings.FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] is true. + + Accepts the following querystring parameters: + * `username`, `email`, and `password` for the user account + * `full_name` for the user profile (the user's full name; defaults to the username) + * `staff`: Set to "true" to make the user global staff. + * `course_id`: Enroll the student in the course with `course_id` + * `roles`: Comma-separated list of roles to grant the student in the course with `course_id` + * `no_login`: Define this to create the user but not login + * `redirect`: Set to "true" will redirect to the `redirect_to` value if set, or + course home page if course_id is defined, otherwise it will redirect to dashboard + * `redirect_to`: will redirect to to this url + * `is_active` : make/update account with status provided as 'is_active' + If username, email, or password are not provided, use + randomly generated credentials. + """ + + # Generate a unique name to use if none provided + generated_username = uuid.uuid4().hex[0:30] + generated_password = generate_password() + + # Use the params from the request, otherwise use these defaults + username = request.GET.get('username', generated_username) + password = request.GET.get('password', generated_password) + email = request.GET.get('email', username + "@example.com") + full_name = request.GET.get('full_name', username) + is_staff = _str2bool(request.GET.get('staff', False)) + is_superuser = _str2bool(request.GET.get('superuser', False)) + course_id = request.GET.get('course_id') + redirect_to = request.GET.get('redirect_to') + is_active = _str2bool(request.GET.get('is_active', True)) + + # Valid modes: audit, credit, honor, no-id-professional, professional, verified + enrollment_mode = request.GET.get('enrollment_mode', 'honor') + + # Parse roles, stripping whitespace, and filtering out empty strings + roles = _clean_roles(request.GET.get('roles', '').split(',')) + course_access_roles = _clean_roles(request.GET.get('course_access_roles', '').split(',')) + + redirect_when_done = _str2bool(request.GET.get('redirect', '')) or redirect_to + login_when_done = 'no_login' not in request.GET + + restricted = settings.FEATURES.get('RESTRICT_AUTOMATIC_AUTH', True) + if is_superuser and restricted: + return HttpResponseForbidden(_('Superuser creation not allowed')) + + form = AccountCreationForm( + data={ + 'username': username, + 'email': email, + 'password': password, + 'name': full_name, + }, + tos_required=False + ) + + # Attempt to create the account. + # If successful, this will return a tuple containing + # the new user object. + try: + user, profile, reg = do_create_account(form) + except (AccountValidationError, ValidationError): + if restricted: + return HttpResponseForbidden(_('Account modification not allowed.')) + # Attempt to retrieve the existing user. + user = User.objects.get(username=username) + user.email = email + user.set_password(password) + user.is_active = is_active + user.save() + profile = UserProfile.objects.get(user=user) + reg = Registration.objects.get(user=user) + except PermissionDenied: + return HttpResponseForbidden(_('Account creation not allowed.')) + + user.is_staff = is_staff + user.is_superuser = is_superuser + user.save() + + if is_active: + reg.activate() + reg.save() + + # ensure parental consent threshold is met + year = datetime.date.today().year + age_limit = settings.PARENTAL_CONSENT_AGE_LIMIT + profile.year_of_birth = (year - age_limit) - 1 + profile.save() + + create_or_set_user_attribute_created_on_site(user, request.site) + + # Enroll the user in a course + course_key = None + if course_id: + course_key = CourseLocator.from_string(course_id) + CourseEnrollment.enroll(user, course_key, mode=enrollment_mode) + + # Apply the roles + for role in roles: + assign_role(course_key, user, role) + + for role in course_access_roles: + CourseAccessRole.objects.update_or_create(user=user, course_id=course_key, org=course_key.org, role=role) + + # Log in as the user + if login_when_done: + user = authenticate_new_user(request, username, password) + django_login(request, user) + + create_comments_service_user(user) + + if redirect_when_done: + if redirect_to: + # Redirect to page specified by the client + redirect_url = redirect_to + elif course_id: + # Redirect to the course homepage (in LMS) or outline page (in Studio) + try: + redirect_url = reverse(course_home_url_name(course_key), kwargs={'course_id': course_id}) + except NoReverseMatch: + redirect_url = reverse('course_handler', kwargs={'course_key_string': course_id}) + else: + # Redirect to the learner dashboard (in LMS) or homepage (in Studio) + try: + redirect_url = reverse('dashboard') + except NoReverseMatch: + redirect_url = reverse('home') + + return redirect(redirect_url) + else: + response = JsonResponse({ + 'created_status': 'Logged in' if login_when_done else 'Created', + 'username': username, + 'email': email, + 'password': password, + 'user_id': user.id, + 'anonymous_id': anonymous_id_for_user(user, None), + }) + response.set_cookie('csrftoken', csrf(request)['csrf_token']) + return response + + +def _clean_roles(roles): + """ Clean roles. + + Strips whitespace from roles, and removes empty items. + + Args: + roles (str[]): List of role names. + + Returns: + str[] + """ + roles = [role.strip() for role in roles] + roles = [role for role in roles if role] + return roles + + +def _str2bool(s): + s = str(s) + return s.lower() in ('yes', 'true', 't', '1') diff --git a/openedx/core/djangoapps/user_authn/views/deprecated.py b/openedx/core/djangoapps/user_authn/views/deprecated.py new file mode 100644 index 000000000000..79bc813a780b --- /dev/null +++ b/openedx/core/djangoapps/user_authn/views/deprecated.py @@ -0,0 +1,162 @@ +""" User Authn code for deprecated views. """ +import warnings + +from django.conf import settings +from django.contrib import messages +from django.core.validators import ValidationError +from django.db import transaction +from django.http import HttpResponseForbidden +from django.shortcuts import redirect +from django.utils.translation import ugettext as _ +from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie +from six import text_type, iteritems + +from edxmako.shortcuts import render_to_response + +from openedx.core.djangoapps.user_authn.views.register import create_account_with_params +from openedx.core.djangoapps.user_authn.cookies import set_logged_in_cookies +from openedx.core.djangoapps.external_auth.login_and_register import login as external_auth_login +from openedx.core.djangoapps.external_auth.login_and_register import register as external_auth_register +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, SYSTEM_MAINTENANCE_MSG, waffle +from student.helpers import ( + auth_pipeline_urls, + get_next_url_for_login_page +) +from student.helpers import AccountValidationError +import third_party_auth +from third_party_auth import pipeline, provider +from util.json_request import JsonResponse + + +@ensure_csrf_cookie +def signin_user(request): + """Deprecated. To be replaced by :class:`user_authn.views.login_form.login_and_registration_form`.""" + external_auth_response = external_auth_login(request) + if external_auth_response is not None: + return external_auth_response + # Determine the URL to redirect to following login: + redirect_to = get_next_url_for_login_page(request) + if request.user.is_authenticated: + return redirect(redirect_to) + + third_party_auth_error = None + for msg in messages.get_messages(request): + if msg.extra_tags.split()[0] == "social-auth": + # msg may or may not be translated. Try translating [again] in case we are able to: + third_party_auth_error = _(text_type(msg)) + break + + context = { + 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header + # Bool injected into JS to submit form if we're inside a running third- + # party auth pipeline; distinct from the actual instance of the running + # pipeline, if any. + 'pipeline_running': 'true' if pipeline.running(request) else 'false', + 'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to), + 'platform_name': configuration_helpers.get_value( + 'platform_name', + settings.PLATFORM_NAME + ), + 'third_party_auth_error': third_party_auth_error + } + + return render_to_response('login.html', context) + + +@ensure_csrf_cookie +def register_user(request, extra_context=None): + """ + Deprecated. To be replaced by :class:`user_authn.views.login_form.login_and_registration_form`. + """ + # Determine the URL to redirect to following login: + redirect_to = get_next_url_for_login_page(request) + if request.user.is_authenticated: + return redirect(redirect_to) + + external_auth_response = external_auth_register(request) + if external_auth_response is not None: + return external_auth_response + + context = { + 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header + 'email': '', + 'name': '', + 'running_pipeline': None, + 'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, redirect_url=redirect_to), + 'platform_name': configuration_helpers.get_value( + 'platform_name', + settings.PLATFORM_NAME + ), + 'selected_provider': '', + 'username': '', + } + + if extra_context is not None: + context.update(extra_context) + + if context.get("extauth_domain", '').startswith(settings.SHIBBOLETH_DOMAIN_PREFIX): + return render_to_response('register-shib.html', context) + + # If third-party auth is enabled, prepopulate the form with data from the + # selected provider. + if third_party_auth.is_enabled() and pipeline.running(request): + running_pipeline = pipeline.get(request) + current_provider = provider.Registry.get_from_pipeline(running_pipeline) + if current_provider is not None: + overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs')) + overrides['running_pipeline'] = running_pipeline + overrides['selected_provider'] = current_provider.name + context.update(overrides) + + return render_to_response('register.html', context) + + +@csrf_exempt +@transaction.non_atomic_requests +def create_account(request, post_override=None): + """ + Deprecated. Use RegistrationView instead. + JSON call to create new edX account. + Used by form in signup_modal.html, which is included into header.html + """ + # Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation + if not configuration_helpers.get_value( + 'ALLOW_PUBLIC_ACCOUNT_CREATION', + settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True) + ): + return HttpResponseForbidden(_("Account creation not allowed.")) + + if waffle().is_enabled(PREVENT_AUTH_USER_WRITES): + return HttpResponseForbidden(SYSTEM_MAINTENANCE_MSG) + + warnings.warn("Please use RegistrationView instead.", DeprecationWarning) + + try: + user = create_account_with_params(request, post_override or request.POST) + except AccountValidationError as exc: + return JsonResponse({'success': False, 'value': text_type(exc), 'field': exc.field}, status=400) + except ValidationError as exc: + field, error_list = next(iteritems(exc.message_dict)) + return JsonResponse( + { + "success": False, + "field": field, + "value": error_list[0], + }, + status=400 + ) + + redirect_url = None # The AJAX method calling should know the default destination upon success + + # Resume the third-party-auth pipeline if necessary. + if third_party_auth.is_enabled() and pipeline.running(request): + running_pipeline = pipeline.get(request) + redirect_url = pipeline.get_complete_url(running_pipeline['backend']) + + response = JsonResponse({ + 'success': True, + 'redirect_url': redirect_url, + }) + set_logged_in_cookies(request, response, user) + return response diff --git a/openedx/core/djangoapps/user_authn/views/login.py b/openedx/core/djangoapps/user_authn/views/login.py new file mode 100644 index 000000000000..96af4156d900 --- /dev/null +++ b/openedx/core/djangoapps/user_authn/views/login.py @@ -0,0 +1,395 @@ +""" +Views for login / logout and associated functionality + +Much of this file was broken out from views.py, previous history can be found there. +""" + +import logging + +import analytics +from django.conf import settings +from django.contrib.auth import authenticate, login as django_login +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User +from django.urls import reverse +from django.http import HttpResponse +from django.utils.translation import ugettext as _ +from django.views.decorators.csrf import ensure_csrf_cookie +from django.views.decorators.http import require_http_methods +from ratelimitbackend.exceptions import RateLimitException + +from edxmako.shortcuts import render_to_response +from eventtracking import tracker +from openedx.core.djangoapps.user_authn.cookies import set_logged_in_cookies +from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError +import openedx.core.djangoapps.external_auth.views +from openedx.core.djangoapps.external_auth.models import ExternalAuthMap +from openedx.core.djangoapps.password_policy import compliance as password_policy_compliance +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.util.user_messages import PageLevelMessages +from student.models import ( + LoginFailures, + PasswordHistory, +) +from student.views import send_reactivation_email_for_user +import third_party_auth +from third_party_auth import pipeline, provider +from util.json_request import JsonResponse + +log = logging.getLogger("edx.student") +AUDIT_LOG = logging.getLogger("audit") + + +def _do_third_party_auth(request): + """ + User is already authenticated via 3rd party, now try to find and return their associated Django user. + """ + running_pipeline = pipeline.get(request) + username = running_pipeline['kwargs'].get('username') + backend_name = running_pipeline['backend'] + third_party_uid = running_pipeline['kwargs']['uid'] + requested_provider = provider.Registry.get_from_pipeline(running_pipeline) + platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME) + + try: + return pipeline.get_authenticated_user(requested_provider, username, third_party_uid) + except User.DoesNotExist: + AUDIT_LOG.info( + u"Login failed - user with username {username} has no social auth " + "with backend_name {backend_name}".format( + username=username, backend_name=backend_name) + ) + message = _( + "You've successfully logged into your {provider_name} account, " + "but this account isn't linked with an {platform_name} account yet." + ).format( + platform_name=platform_name, + provider_name=requested_provider.name, + ) + message += "

" + message += _( + "Use your {platform_name} username and password to log into {platform_name} below, " + "and then link your {platform_name} account with {provider_name} from your dashboard." + ).format( + platform_name=platform_name, + provider_name=requested_provider.name, + ) + message += "

" + message += _( + "If you don't have an {platform_name} account yet, " + "click Register at the top of the page." + ).format( + platform_name=platform_name + ) + + raise AuthFailedError(message) + + +def _get_user_by_email(request): + """ + Finds a user object in the database based on the given request, ignores all fields except for email. + """ + if 'email' not in request.POST or 'password' not in request.POST: + raise AuthFailedError(_('There was an error receiving your login information. Please email us.')) + + email = request.POST['email'] + + try: + return User.objects.get(email=email) + except User.DoesNotExist: + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + AUDIT_LOG.warning(u"Login failed - Unknown user email") + else: + AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email)) + + +def _check_shib_redirect(user): + """ + See if the user has a linked shibboleth account, if so, redirect the user to shib-login. + This behavior is pretty much like what gmail does for shibboleth. Try entering some @stanford.edu + address into the Gmail login. + """ + if settings.FEATURES.get('AUTH_USE_SHIB') and user: + try: + eamap = ExternalAuthMap.objects.get(user=user) + if eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX): + raise AuthFailedError('', redirect=reverse('shib-login')) + except ExternalAuthMap.DoesNotExist: + # This is actually the common case, logging in user without external linked login + AUDIT_LOG.info(u"User %s w/o external auth attempting login", user) + + +def _check_excessive_login_attempts(user): + """ + See if account has been locked out due to excessive login failures + """ + if user and LoginFailures.is_feature_enabled(): + if LoginFailures.is_user_locked_out(user): + raise AuthFailedError(_('This account has been temporarily locked due ' + 'to excessive login failures. Try again later.')) + + +def _check_forced_password_reset(user): + """ + See if the user must reset his/her password due to any policy settings + """ + if user and PasswordHistory.should_user_reset_password_now(user): + raise AuthFailedError(_('Your password has expired due to password policy on this account. You must ' + 'reset your password before you can log in again. Please click the ' + '"Forgot Password" link on this page to reset your password before logging in again.')) + + +def _enforce_password_policy_compliance(request, user): + try: + password_policy_compliance.enforce_compliance_on_login(user, request.POST.get('password')) + except password_policy_compliance.NonCompliantPasswordWarning as e: + # Allow login, but warn the user that they will be required to reset their password soon. + PageLevelMessages.register_warning_message(request, e.message) + except password_policy_compliance.NonCompliantPasswordException as e: + # Prevent the login attempt. + raise AuthFailedError(e.message) + + +def _generate_not_activated_message(user): + """ + Generates the message displayed on the sign-in screen when a learner attempts to access the + system with an inactive account. + """ + + support_url = configuration_helpers.get_value( + 'SUPPORT_SITE_LINK', + settings.SUPPORT_SITE_LINK + ) + + platform_name = configuration_helpers.get_value( + 'PLATFORM_NAME', + settings.PLATFORM_NAME + ) + + not_activated_msg_template = _('In order to sign in, you need to activate your account.

' + 'We just sent an activation link to {email}. If ' + 'you do not receive an email, check your spam folders or ' + 'contact {platform} Support.') + + not_activated_message = not_activated_msg_template.format( + email=user.email, + support_url=support_url, + platform=platform_name + ) + + return not_activated_message + + +def _log_and_raise_inactive_user_auth_error(unauthenticated_user): + """ + Depending on Django version we can get here a couple of ways, but this takes care of logging an auth attempt + by an inactive user, re-sending the activation email, and raising an error with the correct message. + """ + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + AUDIT_LOG.warning( + u"Login failed - Account not active for user.id: {0}, resending activation".format( + unauthenticated_user.id) + ) + else: + AUDIT_LOG.warning(u"Login failed - Account not active for user {0}, resending activation".format( + unauthenticated_user.username) + ) + + send_reactivation_email_for_user(unauthenticated_user) + raise AuthFailedError(_generate_not_activated_message(unauthenticated_user)) + + +def _authenticate_first_party(request, unauthenticated_user): + """ + Use Django authentication on the given request, using rate limiting if configured + """ + + # If the user doesn't exist, we want to set the username to an invalid username so that authentication is guaranteed + # to fail and we can take advantage of the ratelimited backend + username = unauthenticated_user.username if unauthenticated_user else "" + + try: + return authenticate( + username=username, + password=request.POST['password'], + request=request) + + # This occurs when there are too many attempts from the same IP address + except RateLimitException: + raise AuthFailedError(_('Too many failed login attempts. Try again later.')) + + +def _handle_failed_authentication(user): + """ + Handles updating the failed login count, inactive user notifications, and logging failed authentications. + """ + if user: + if LoginFailures.is_feature_enabled(): + LoginFailures.increment_lockout_counter(user) + + if not user.is_active: + _log_and_raise_inactive_user_auth_error(user) + + # if we didn't find this username earlier, the account for this email + # doesn't exist, and doesn't have a corresponding password + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + loggable_id = user.id if user else "" + AUDIT_LOG.warning(u"Login failed - password for user.id: {0} is invalid".format(loggable_id)) + else: + AUDIT_LOG.warning(u"Login failed - password for {0} is invalid".format(user.email)) + + raise AuthFailedError(_('Email or password is incorrect.')) + + +def _handle_successful_authentication_and_login(user, request): + """ + Handles clearing the failed login counter, login tracking, and setting session timeout. + """ + if LoginFailures.is_feature_enabled(): + LoginFailures.clear_lockout_counter(user) + + _track_user_login(user, request) + + try: + django_login(request, user) + if request.POST.get('remember') == 'true': + request.session.set_expiry(604800) + log.debug("Setting user session to never expire") + else: + request.session.set_expiry(0) + except Exception as exc: + AUDIT_LOG.critical("Login failed - Could not create session. Is memcached running?") + log.critical("Login failed - Could not create session. Is memcached running?") + log.exception(exc) + raise + + +def _track_user_login(user, request): + """ + Sends a tracking event for a successful login. + """ + if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: + tracking_context = tracker.get_tracker().resolve_context() + analytics.identify( + user.id, + { + 'email': request.POST['email'], + 'username': user.username + }, + { + # Disable MailChimp because we don't want to update the user's email + # and username in MailChimp on every page load. We only need to capture + # this data on registration/activation. + 'MailChimp': False + } + ) + + analytics.track( + user.id, + "edx.bi.user.account.authenticated", + { + 'category': "conversion", + 'label': request.POST.get('course_id'), + 'provider': None + }, + context={ + 'ip': tracking_context.get('ip'), + 'Google Analytics': { + 'clientId': tracking_context.get('client_id') + } + } + ) + + +@login_required +@require_http_methods(['GET']) +def finish_auth(request): # pylint: disable=unused-argument + """ Following logistration (1st or 3rd party), handle any special query string params. + + See FinishAuthView.js for details on the query string params. + + e.g. auto-enroll the user in a course, set email opt-in preference. + + This view just displays a "Please wait" message while AJAX calls are made to enroll the + user in the course etc. This view is only used if a parameter like "course_id" is present + during login/registration/third_party_auth. Otherwise, there is no need for it. + + Ideally this view will finish and redirect to the next step before the user even sees it. + + Args: + request (HttpRequest) + + Returns: + HttpResponse: 200 if the page was sent successfully + HttpResponse: 302 if not logged in (redirect to login page) + HttpResponse: 405 if using an unsupported HTTP method + + Example usage: + + GET /account/finish_auth/?course_id=course-v1:blah&enrollment_action=enroll + + """ + return render_to_response('student_account/finish_auth.html', { + 'disable_courseware_js': True, + 'disable_footer': True, + }) + + +@ensure_csrf_cookie +def login_user(request): + """ + AJAX request to log in the user. + """ + third_party_auth_requested = third_party_auth.is_enabled() and pipeline.running(request) + trumped_by_first_party_auth = bool(request.POST.get('email')) or bool(request.POST.get('password')) + was_authenticated_third_party = False + + try: + if third_party_auth_requested and not trumped_by_first_party_auth: + # The user has already authenticated via third-party auth and has not + # asked to do first party auth by supplying a username or password. We + # now want to put them through the same logging and cookie calculation + # logic as with first-party auth. + + # This nested try is due to us only returning an HttpResponse in this + # one case vs. JsonResponse everywhere else. + try: + email_user = _do_third_party_auth(request) + was_authenticated_third_party = True + except AuthFailedError as e: + return HttpResponse(e.value, content_type="text/plain", status=403) + else: + email_user = _get_user_by_email(request) + + _check_shib_redirect(email_user) + _check_excessive_login_attempts(email_user) + _check_forced_password_reset(email_user) + + possibly_authenticated_user = email_user + + if not was_authenticated_third_party: + possibly_authenticated_user = _authenticate_first_party(request, email_user) + if possibly_authenticated_user and password_policy_compliance.should_enforce_compliance_on_login(): + # Important: This call must be made AFTER the user was successfully authenticated. + _enforce_password_policy_compliance(request, possibly_authenticated_user) + + if possibly_authenticated_user is None or not possibly_authenticated_user.is_active: + _handle_failed_authentication(email_user) + + _handle_successful_authentication_and_login(possibly_authenticated_user, request) + + redirect_url = None # The AJAX method calling should know the default destination upon success + if was_authenticated_third_party: + running_pipeline = pipeline.get(request) + redirect_url = pipeline.get_complete_url(backend_name=running_pipeline['backend']) + + response = JsonResponse({ + 'success': True, + 'redirect_url': redirect_url, + }) + + # Ensure that the external marketing site can + # detect that the user is logged in. + return set_logged_in_cookies(request, response, possibly_authenticated_user) + except AuthFailedError as error: + return JsonResponse(error.get_response()) diff --git a/openedx/core/djangoapps/user_authn/views/login_form.py b/openedx/core/djangoapps/user_authn/views/login_form.py new file mode 100644 index 000000000000..900f9b90386b --- /dev/null +++ b/openedx/core/djangoapps/user_authn/views/login_form.py @@ -0,0 +1,259 @@ +""" Login related views """ + +import json +import logging + +import urlparse +from django.conf import settings +from django.contrib import messages +from django.shortcuts import redirect +from django.utils.translation import ugettext as _ +from django.views.decorators.csrf import ensure_csrf_cookie +from django.views.decorators.http import require_http_methods + +from edxmako.shortcuts import render_to_response +from openedx.core.djangoapps.user_authn.views.deprecated import ( + register_user as old_register_view, signin_user as old_login_view +) +from openedx.core.djangoapps.external_auth.login_and_register import login as external_auth_login +from openedx.core.djangoapps.external_auth.login_and_register import register as external_auth_register +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.theming.helpers import is_request_in_themed_site +from openedx.core.djangoapps.user_api.api import ( + RegistrationFormFactory, + get_login_session_form, + 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, + update_logistration_context_for_enterprise, +) +from student.helpers import get_next_url_for_login_page +import third_party_auth +from third_party_auth import pipeline +from third_party_auth.decorators import xframe_allow_whitelisted + + +log = logging.getLogger(__name__) + + +@require_http_methods(['GET']) +@ensure_csrf_cookie +@xframe_allow_whitelisted +def login_and_registration_form(request, initial_mode="login"): + """Render the combined login/registration form, defaulting to login + + This relies on the JS to asynchronously load the actual form from + the user_api. + + Keyword Args: + initial_mode (string): Either "login" or "register". + + """ + # Determine the URL to redirect to following login/registration/third_party_auth + redirect_to = get_next_url_for_login_page(request) + # If we're already logged in, redirect to the dashboard + if request.user.is_authenticated: + return redirect(redirect_to) + + # Retrieve the form descriptions from the user API + form_descriptions = _get_form_descriptions(request) + + # Our ?next= URL may itself contain a parameter 'tpa_hint=x' that we need to check. + # If present, we display a login page focused on third-party auth with that provider. + third_party_auth_hint = None + if '?' in redirect_to: + try: + next_args = urlparse.parse_qs(urlparse.urlparse(redirect_to).query) + provider_id = next_args['tpa_hint'][0] + tpa_hint_provider = third_party_auth.provider.Registry.get(provider_id=provider_id) + if tpa_hint_provider: + if tpa_hint_provider.skip_hinted_login_dialog: + # Forward the user directly to the provider's login URL when the provider is configured + # to skip the dialog. + if initial_mode == "register": + auth_entry = pipeline.AUTH_ENTRY_REGISTER + else: + auth_entry = pipeline.AUTH_ENTRY_LOGIN + return redirect( + pipeline.get_login_url(provider_id, auth_entry, redirect_url=redirect_to) + ) + third_party_auth_hint = provider_id + initial_mode = "hinted_login" + except (KeyError, ValueError, IndexError) as ex: + log.exception("Unknown tpa_hint provider: %s", ex) + + # If this is a themed site, revert to the old login/registration pages. + # We need to do this for now to support existing themes. + # Themed sites can use the new logistration page by setting + # 'ENABLE_COMBINED_LOGIN_REGISTRATION' in their + # configuration settings. + if is_request_in_themed_site() and not configuration_helpers.get_value('ENABLE_COMBINED_LOGIN_REGISTRATION', False): + if initial_mode == "login": + return old_login_view(request) + elif initial_mode == "register": + return old_register_view(request) + + # Allow external auth to intercept and handle the request + ext_auth_response = _external_auth_intercept(request, initial_mode) + if ext_auth_response is not None: + return ext_auth_response + + # Account activation message + account_activation_messages = [ + { + 'message': message.message, 'tags': message.tags + } for message in messages.get_messages(request) if 'account-activation' in message.tags + ] + + # Otherwise, render the combined login/registration page + context = { + 'data': { + 'login_redirect_url': redirect_to, + 'initial_mode': initial_mode, + 'third_party_auth': _third_party_auth_context(request, redirect_to, third_party_auth_hint), + 'third_party_auth_hint': third_party_auth_hint or '', + 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), + 'support_link': configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK), + 'password_reset_support_link': configuration_helpers.get_value( + 'PASSWORD_RESET_SUPPORT_LINK', settings.PASSWORD_RESET_SUPPORT_LINK + ) or settings.SUPPORT_SITE_LINK, + 'account_activation_messages': account_activation_messages, + + # Include form descriptions retrieved from the user API. + # We could have the JS client make these requests directly, + # but we include them in the initial page load to avoid + # the additional round-trip to the server. + 'login_form_desc': json.loads(form_descriptions['login']), + 'registration_form_desc': json.loads(form_descriptions['registration']), + 'password_reset_form_desc': json.loads(form_descriptions['password_reset']), + 'account_creation_allowed': configuration_helpers.get_value( + 'ALLOW_PUBLIC_ACCOUNT_CREATION', settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True)) + }, + 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in header + 'responsive': True, + 'allow_iframing': True, + 'disable_courseware_js': True, + 'combined_login_and_register': True, + 'disable_footer': not configuration_helpers.get_value( + 'ENABLE_COMBINED_LOGIN_REGISTRATION_FOOTER', + settings.FEATURES['ENABLE_COMBINED_LOGIN_REGISTRATION_FOOTER'] + ), + } + + enterprise_customer = enterprise_customer_for_request(request) + update_logistration_context_for_enterprise(request, context, enterprise_customer) + + response = render_to_response('student_account/login_and_register.html', context) + handle_enterprise_cookies_for_logistration(request, response, context) + + return response + + +def _get_form_descriptions(request): + """Retrieve form descriptions from the user API. + + Arguments: + request (HttpRequest): The original request, used to retrieve session info. + + Returns: + dict: Keys are 'login', 'registration', and 'password_reset'; + values are the JSON-serialized form descriptions. + + """ + + return { + 'password_reset': get_password_reset_form().to_json(), + 'login': get_login_session_form(request).to_json(), + 'registration': RegistrationFormFactory().get_registration_form(request).to_json() + } + + +def _third_party_auth_context(request, redirect_to, tpa_hint=None): + """Context for third party auth providers and the currently running pipeline. + + Arguments: + request (HttpRequest): The request, used to determine if a pipeline + is currently running. + redirect_to: The URL to send the user to following successful + authentication. + tpa_hint (string): An override flag that will return a matching provider + as long as its configuration has been enabled + + Returns: + dict + + """ + context = { + "currentProvider": None, + "providers": [], + "secondaryProviders": [], + "finishAuthUrl": None, + "errorMessage": None, + "registerFormSubmitButtonText": _("Create Account"), + "syncLearnerProfileData": False, + "pipeline_user_details": {} + } + + if third_party_auth.is_enabled(): + for enabled in third_party_auth.provider.Registry.displayed_for_login(tpa_hint=tpa_hint): + info = { + "id": enabled.provider_id, + "name": enabled.name, + "iconClass": enabled.icon_class or None, + "iconImage": enabled.icon_image.url if enabled.icon_image else None, + "loginUrl": pipeline.get_login_url( + enabled.provider_id, + pipeline.AUTH_ENTRY_LOGIN, + redirect_url=redirect_to, + ), + "registerUrl": pipeline.get_login_url( + enabled.provider_id, + pipeline.AUTH_ENTRY_REGISTER, + redirect_url=redirect_to, + ), + } + context["providers" if not enabled.secondary else "secondaryProviders"].append(info) + + running_pipeline = pipeline.get(request) + if running_pipeline is not None: + current_provider = third_party_auth.provider.Registry.get_from_pipeline(running_pipeline) + user_details = running_pipeline['kwargs']['details'] + if user_details: + context['pipeline_user_details'] = user_details + + if current_provider is not None: + context["currentProvider"] = current_provider.name + context["finishAuthUrl"] = pipeline.get_complete_url(current_provider.backend_name) + context["syncLearnerProfileData"] = current_provider.sync_learner_profile_data + + if current_provider.skip_registration_form: + # As a reliable way of "skipping" the registration form, we just submit it automatically + context["autoSubmitRegForm"] = True + + # Check for any error messages we may want to display: + for msg in messages.get_messages(request): + if msg.extra_tags.split()[0] == "social-auth": + # msg may or may not be translated. Try translating [again] in case we are able to: + context['errorMessage'] = _(unicode(msg)) + break + + return context + + +def _external_auth_intercept(request, mode): + """Allow external auth to intercept a login/registration request. + + Arguments: + request (Request): The original request. + mode (str): Either "login" or "register" + + Returns: + Response or None + + """ + if mode == "login": + return external_auth_login(request) + elif mode == "register": + return external_auth_register(request) diff --git a/openedx/core/djangoapps/user_authn/views/logout.py b/openedx/core/djangoapps/user_authn/views/logout.py new file mode 100644 index 000000000000..dc7d311a59af --- /dev/null +++ b/openedx/core/djangoapps/user_authn/views/logout.py @@ -0,0 +1,101 @@ +""" Views related to logout. """ +from urlparse import parse_qs, urlsplit, urlunsplit + +import edx_oauth2_provider +from django.conf import settings +from django.contrib.auth import logout +from django.urls import reverse_lazy +from django.shortcuts import redirect +from django.utils.http import is_safe_url, urlencode +from django.views.generic import TemplateView +from provider.oauth2.models import Client +from openedx.core.djangoapps.user_authn.cookies import delete_logged_in_cookies + + +class LogoutView(TemplateView): + """ + Logs out user and redirects. + + The template should load iframes to log the user out of OpenID Connect services. + See http://openid.net/specs/openid-connect-logout-1_0.html. + """ + oauth_client_ids = [] + template_name = 'logout.html' + + # Keep track of the page to which the user should ultimately be redirected. + default_target = reverse_lazy('cas-logout') if settings.FEATURES.get('AUTH_USE_CAS') else '/' + + @property + def target(self): + """ + If a redirect_url is specified in the querystring for this request, and the value is a url + with the same host, the view will redirect to this page after rendering the template. + If it is not specified, we will use the default target url. + """ + target_url = self.request.GET.get('redirect_url') + + if target_url and is_safe_url( + target_url, + allowed_hosts={self.request.META.get('HTTP_HOST')}, + require_https=True, + ): + return target_url + else: + return self.default_target + + def dispatch(self, request, *args, **kwargs): + # We do not log here, because we have a handler registered to perform logging on successful logouts. + request.is_from_logout = True + + # Get the list of authorized clients before we clear the session. + self.oauth_client_ids = request.session.get(edx_oauth2_provider.constants.AUTHORIZED_CLIENTS_SESSION_KEY, []) + + logout(request) + + # If we don't need to deal with OIDC logouts, just redirect the user. + if self.oauth_client_ids: + response = super(LogoutView, self).dispatch(request, *args, **kwargs) + else: + response = redirect(self.target) + + # Clear the cookie used by the edx.org marketing site + delete_logged_in_cookies(response) + + return response + + def _build_logout_url(self, url): + """ + Builds a logout URL with the `no_redirect` query string parameter. + + Args: + url (str): IDA logout URL + + Returns: + str + """ + scheme, netloc, path, query_string, fragment = urlsplit(url) + query_params = parse_qs(query_string) + query_params['no_redirect'] = 1 + new_query_string = urlencode(query_params, doseq=True) + return urlunsplit((scheme, netloc, path, new_query_string, fragment)) + + def get_context_data(self, **kwargs): + context = super(LogoutView, self).get_context_data(**kwargs) + + # Create a list of URIs that must be called to log the user out of all of the IDAs. + uris = Client.objects.filter(client_id__in=self.oauth_client_ids, + logout_uri__isnull=False).values_list('logout_uri', flat=True) + + referrer = self.request.META.get('HTTP_REFERER', '').strip('/') + logout_uris = [] + + for uri in uris: + if not referrer or (referrer and not uri.startswith(referrer)): + logout_uris.append(self._build_logout_url(uri)) + + context.update({ + 'target': self.target, + 'logout_uris': logout_uris, + }) + + return context diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py new file mode 100644 index 000000000000..951cf9120bf1 --- /dev/null +++ b/openedx/core/djangoapps/user_authn/views/register.py @@ -0,0 +1,467 @@ +""" +Registration related views. +""" + +import datetime +import json +import logging + +import analytics +import dogstats_wrapper as dog_stats_api +from django.conf import settings +from django.contrib.auth import login as django_login +from django.contrib.auth.models import User +from django.urls import reverse +from django.core.validators import ValidationError, validate_email +from django.db import transaction +from django.dispatch import Signal +from django.utils.translation import get_language +from django.utils.translation import ugettext as _ +from eventtracking import tracker +# Note that this lives in LMS, so this dependency should be refactored. +from notification_prefs.views import enable_notifications +from pytz import UTC +from requests import HTTPError +from six import text_type +from social_core.exceptions import AuthAlreadyAssociated, AuthException +from social_django import utils as social_utils + +from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.user_api import accounts as accounts_settings +from openedx.core.djangoapps.user_api.accounts.utils import generate_password +from openedx.core.djangoapps.user_api.preferences import api as preferences_api + +from student.forms import AccountCreationForm, get_registration_extension_form +from student.helpers import ( + authenticate_new_user, + create_or_set_user_attribute_created_on_site, + do_create_account, +) +from student.models import ( + RegistrationCookieConfiguration, + UserAttribute, + create_comments_service_user, +) +from student.views import compose_and_send_activation_email +import third_party_auth +from third_party_auth import pipeline, provider +from third_party_auth.saml import SAP_SUCCESSFACTORS_SAML_KEY +from util.db import outer_atomic + + +log = logging.getLogger("edx.student") +AUDIT_LOG = logging.getLogger("audit") + + +# Used as the name of the user attribute for tracking affiliate registrations +REGISTRATION_AFFILIATE_ID = 'registration_affiliate_id' +REGISTRATION_UTM_PARAMETERS = { + 'utm_source': 'registration_utm_source', + 'utm_medium': 'registration_utm_medium', + 'utm_campaign': 'registration_utm_campaign', + 'utm_term': 'registration_utm_term', + 'utm_content': 'registration_utm_content', +} +REGISTRATION_UTM_CREATED_AT = 'registration_utm_created_at' +# used to announce a registration +REGISTER_USER = Signal(providing_args=["user", "registration"]) + + +@transaction.non_atomic_requests +def create_account_with_params(request, params): + """ + Given a request and a dict of parameters (which may or may not have come + from the request), create an account for the requesting user, including + creating a comments service user object and sending an activation email. + This also takes external/third-party auth into account, updates that as + necessary, and authenticates the user for the request's session. + + Does not return anything. + + Raises AccountValidationError if an account with the username or email + specified by params already exists, or ValidationError if any of the given + parameters is invalid for any other reason. + + Issues with this code: + * It is non-transactional except where explicitly wrapped in atomic to + alleviate deadlocks and improve performance. This means failures at + different places in registration can leave users in inconsistent + states. + * Third-party auth passwords are not verified. There is a comment that + they are unused, but it would be helpful to have a sanity check that + they are sane. + * The user-facing text is rather unfriendly (e.g. "Username must be a + minimum of two characters long" rather than "Please use a username of + at least two characters"). + * Duplicate email raises a ValidationError (rather than the expected + AccountValidationError). Duplicate username returns an inconsistent + user message (i.e. "An account with the Public Username '{username}' + already exists." rather than "It looks like {username} belongs to an + existing account. Try again with a different username.") The two checks + occur at different places in the code; as a result, registering with + both a duplicate username and email raises only a ValidationError for + email only. + """ + # Copy params so we can modify it; we can't just do dict(params) because if + # params is request.POST, that results in a dict containing lists of values + params = dict(params.items()) + + # allow to define custom set of required/optional/hidden fields via configuration + extra_fields = configuration_helpers.get_value( + 'REGISTRATION_EXTRA_FIELDS', + getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {}) + ) + # registration via third party (Google, Facebook) using mobile application + # doesn't use social auth pipeline (no redirect uri(s) etc involved). + # In this case all related info (required for account linking) + # is sent in params. + # `third_party_auth_credentials_in_api` essentially means 'request + # is made from mobile application' + third_party_auth_credentials_in_api = 'provider' in params + is_third_party_auth_enabled = third_party_auth.is_enabled() + + if is_third_party_auth_enabled and (pipeline.running(request) or third_party_auth_credentials_in_api): + params["password"] = generate_password() + + # in case user is registering via third party (Google, Facebook) and pipeline has expired, show appropriate + # error message + if is_third_party_auth_enabled and ('social_auth_provider' in params and not pipeline.running(request)): + raise ValidationError( + {'session_expired': [ + _(u"Registration using {provider} has timed out.").format( + provider=params.get('social_auth_provider')) + ]} + ) + + do_external_auth, eamap = pre_account_creation_external_auth(request, params) + + extended_profile_fields = configuration_helpers.get_value('extended_profile_fields', []) + enforce_password_policy = not do_external_auth + # Can't have terms of service for certain SHIB users, like at Stanford + registration_fields = getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {}) + tos_required = ( + registration_fields.get('terms_of_service') != 'hidden' or + registration_fields.get('honor_code') != 'hidden' + ) and ( + not settings.FEATURES.get("AUTH_USE_SHIB") or + not settings.FEATURES.get("SHIB_DISABLE_TOS") or + not do_external_auth or + not eamap.external_domain.startswith(settings.SHIBBOLETH_DOMAIN_PREFIX) + ) + + form = AccountCreationForm( + data=params, + extra_fields=extra_fields, + extended_profile_fields=extended_profile_fields, + enforce_password_policy=enforce_password_policy, + tos_required=tos_required, + ) + custom_form = get_registration_extension_form(data=params) + + # Perform operations within a transaction that are critical to account creation + with outer_atomic(read_committed=True): + # first, create the account + (user, profile, registration) = do_create_account(form, custom_form) + + third_party_provider, running_pipeline = _link_user_to_third_party_provider( + is_third_party_auth_enabled, third_party_auth_credentials_in_api, user, request, params, + ) + + new_user = authenticate_new_user(request, user.username, params['password']) + django_login(request, new_user) + request.session.set_expiry(0) + + post_account_creation_external_auth(do_external_auth, eamap, new_user) + + # Check if system is configured to skip activation email for the current user. + skip_email = _skip_activation_email( + user, do_external_auth, running_pipeline, third_party_provider, + ) + + if skip_email: + registration.activate() + else: + compose_and_send_activation_email(user, profile, registration) + + # Perform operations that are non-critical parts of account creation + create_or_set_user_attribute_created_on_site(user, request.site) + + preferences_api.set_user_preference(user, LANGUAGE_KEY, get_language()) + + if settings.FEATURES.get('ENABLE_DISCUSSION_EMAIL_DIGEST'): + try: + enable_notifications(user) + except Exception: # pylint: disable=broad-except + log.exception("Enable discussion notifications failed for user {id}.".format(id=user.id)) + + dog_stats_api.increment("common.student.account_created") + + _track_user_registration(user, profile, params, third_party_provider) + + # Announce registration + REGISTER_USER.send(sender=None, user=user, registration=registration) + + create_comments_service_user(user) + + try: + _record_registration_attributions(request, new_user) + # Don't prevent a user from registering due to attribution errors. + except Exception: # pylint: disable=broad-except + log.exception('Error while attributing cookies to user registration.') + + # TODO: there is no error checking here to see that the user actually logged in successfully, + # and is not yet an active user. + if new_user is not None: + AUDIT_LOG.info(u"Login success on new account creation - {0}".format(new_user.username)) + + return new_user + + +def pre_account_creation_external_auth(request, params): + """ + External auth related setup before account is created. + """ + # If doing signup for an external authorization, then get email, password, name from the eamap + # don't use the ones from the form, since the user could have hacked those + # unless originally we didn't get a valid email or name from the external auth + # TODO: We do not check whether these values meet all necessary criteria, such as email length + do_external_auth = 'ExternalAuthMap' in request.session + eamap = None + if do_external_auth: + eamap = request.session['ExternalAuthMap'] + try: + validate_email(eamap.external_email) + params["email"] = eamap.external_email + except ValidationError: + pass + if len(eamap.external_name.strip()) >= accounts_settings.NAME_MIN_LENGTH: + params["name"] = eamap.external_name + params["password"] = eamap.internal_password + log.debug(u'In create_account with external_auth: user = %s, email=%s', params["name"], params["email"]) + + return do_external_auth, eamap + + +def post_account_creation_external_auth(do_external_auth, eamap, new_user): + """ + External auth related updates after account is created. + """ + if do_external_auth: + eamap.user = new_user + eamap.dtsignup = datetime.datetime.now(UTC) + eamap.save() + AUDIT_LOG.info(u"User registered with external_auth %s", new_user.username) + AUDIT_LOG.info(u'Updated ExternalAuthMap for %s to be %s', new_user.username, eamap) + + if settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'): + log.info('bypassing activation email') + new_user.is_active = True + new_user.save() + AUDIT_LOG.info( + u"Login activated on extauth account - {0} ({1})".format(new_user.username, new_user.email) + ) + + +def _link_user_to_third_party_provider( + is_third_party_auth_enabled, + third_party_auth_credentials_in_api, + user, + request, + params, +): + """ + If a 3rd party auth provider and credentials were provided in the API, link the account with social auth + (If the user is using the normal register page, the social auth pipeline does the linking, not this code) + + Note: this is orthogonal to the 3rd party authentication pipeline that occurs + when the account is created via the browser and redirect URLs. + """ + third_party_provider, running_pipeline = None, None + if is_third_party_auth_enabled and third_party_auth_credentials_in_api: + backend_name = params['provider'] + request.social_strategy = social_utils.load_strategy(request) + redirect_uri = reverse('social:complete', args=(backend_name, )) + request.backend = social_utils.load_backend(request.social_strategy, backend_name, redirect_uri) + social_access_token = params.get('access_token') + if not social_access_token: + raise ValidationError({ + 'access_token': [ + _("An access_token is required when passing value ({}) for provider.").format( + params['provider'] + ) + ] + }) + request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_REGISTER_API + pipeline_user = None + error_message = "" + try: + pipeline_user = request.backend.do_auth(social_access_token, user=user) + except AuthAlreadyAssociated: + error_message = _("The provided access_token is already associated with another user.") + except (HTTPError, AuthException): + error_message = _("The provided access_token is not valid.") + if not pipeline_user or not isinstance(pipeline_user, User): + # Ensure user does not re-enter the pipeline + request.social_strategy.clean_partial_pipeline(social_access_token) + raise ValidationError({'access_token': [error_message]}) + + # If the user is registering via 3rd party auth, track which provider they use + if is_third_party_auth_enabled and pipeline.running(request): + running_pipeline = pipeline.get(request) + third_party_provider = provider.Registry.get_from_pipeline(running_pipeline) + + return third_party_provider, running_pipeline + + +def _track_user_registration(user, profile, params, third_party_provider): + """ Track the user's registration. """ + if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: + tracking_context = tracker.get_tracker().resolve_context() + identity_args = [ + user.id, + { + 'email': user.email, + 'username': user.username, + 'name': profile.name, + # Mailchimp requires the age & yearOfBirth to be integers, we send a sane integer default if falsey. + 'age': profile.age or -1, + 'yearOfBirth': profile.year_of_birth or datetime.datetime.now(UTC).year, + 'education': profile.level_of_education_display, + 'address': profile.mailing_address, + 'gender': profile.gender_display, + 'country': text_type(profile.country), + } + ] + + if hasattr(settings, 'MAILCHIMP_NEW_USER_LIST_ID'): + identity_args.append({ + "MailChimp": { + "listId": settings.MAILCHIMP_NEW_USER_LIST_ID + } + }) + + analytics.identify(*identity_args) + + analytics.track( + user.id, + "edx.bi.user.account.registered", + { + 'category': 'conversion', + 'label': params.get('course_id'), + 'provider': third_party_provider.name if third_party_provider else None + }, + context={ + 'ip': tracking_context.get('ip'), + 'Google Analytics': { + 'clientId': tracking_context.get('client_id') + } + } + ) + + +def _skip_activation_email(user, do_external_auth, running_pipeline, third_party_provider): + """ + Return `True` if activation email should be skipped. + + Skip email if we are: + 1. Doing load testing. + 2. Random user generation for other forms of testing. + 3. External auth bypassing activation. + 4. Have the platform configured to not require e-mail activation. + 5. Registering a new user using a trusted third party provider (with skip_email_verification=True) + + Note that this feature is only tested as a flag set one way or + the other for *new* systems. we need to be careful about + changing settings on a running system to make sure no users are + left in an inconsistent state (or doing a migration if they are). + + Arguments: + user (User): Django User object for the current user. + do_external_auth (bool): True if external authentication is in progress. + running_pipeline (dict): Dictionary containing user and pipeline data for third party authentication. + third_party_provider (ProviderConfig): An instance of third party provider configuration. + + Returns: + (bool): `True` if account activation email should be skipped, `False` if account activation email should be + sent. + """ + sso_pipeline_email = running_pipeline and running_pipeline['kwargs'].get('details', {}).get('email') + + # Email is valid if the SAML assertion email matches the user account email or + # no email was provided in the SAML assertion. Some IdP's use a callback + # to retrieve additional user account information (including email) after the + # initial account creation. + valid_email = ( + sso_pipeline_email == user.email or ( + sso_pipeline_email is None and + third_party_provider and + getattr(third_party_provider, "identity_provider_type", None) == SAP_SUCCESSFACTORS_SAML_KEY + ) + ) + + # log the cases where skip activation email flag is set, but email validity check fails + if third_party_provider and third_party_provider.skip_email_verification and not valid_email: + log.info( + '[skip_email_verification=True][user=%s][pipeline-email=%s][identity_provider=%s][provider_type=%s] ' + 'Account activation email sent as user\'s system email differs from SSO email.', + user.email, + sso_pipeline_email, + getattr(third_party_provider, "provider_id", None), + getattr(third_party_provider, "identity_provider_type", None) + ) + + return ( + settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) or + settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') or + (settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH') and do_external_auth) or + (third_party_provider and third_party_provider.skip_email_verification and valid_email) + ) + + +def _record_registration_attributions(request, user): + """ + Attribute this user's registration based on referrer cookies. + """ + _record_affiliate_registration_attribution(request, user) + _record_utm_registration_attribution(request, user) + + +def _record_affiliate_registration_attribution(request, user): + """ + Attribute this user's registration to the referring affiliate, if + applicable. + """ + affiliate_id = request.COOKIES.get(settings.AFFILIATE_COOKIE_NAME) + if user and affiliate_id: + UserAttribute.set_user_attribute(user, REGISTRATION_AFFILIATE_ID, affiliate_id) + + +def _record_utm_registration_attribution(request, user): + """ + Attribute this user's registration to the latest UTM referrer, if + applicable. + """ + utm_cookie_name = RegistrationCookieConfiguration.current().utm_cookie_name + utm_cookie = request.COOKIES.get(utm_cookie_name) + if user and utm_cookie: + utm = json.loads(utm_cookie) + for utm_parameter_name in REGISTRATION_UTM_PARAMETERS: + utm_parameter = utm.get(utm_parameter_name) + if utm_parameter: + UserAttribute.set_user_attribute( + user, + REGISTRATION_UTM_PARAMETERS.get(utm_parameter_name), + utm_parameter + ) + created_at_unixtime = utm.get('created_at') + if created_at_unixtime: + # We divide by 1000 here because the javascript timestamp generated is in milliseconds not seconds. + # PYTHON: time.time() => 1475590280.823698 + # JS: new Date().getTime() => 1475590280823 + created_at_datetime = datetime.datetime.fromtimestamp(int(created_at_unixtime) / float(1000), tz=UTC) + UserAttribute.set_user_attribute( + user, + REGISTRATION_UTM_CREATED_AT, + created_at_datetime + ) diff --git a/openedx/core/djangoapps/user_authn/views/tests/__init__.py b/openedx/core/djangoapps/user_authn/views/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/common/djangoapps/student/tests/test_auto_auth.py b/openedx/core/djangoapps/user_authn/views/tests/test_auto_auth.py similarity index 96% rename from common/djangoapps/student/tests/test_auto_auth.py rename to openedx/core/djangoapps/user_authn/views/tests/test_auto_auth.py index 176e916dadd6..78b34ba6a2d4 100644 --- a/common/djangoapps/student/tests/test_auto_auth.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_auto_auth.py @@ -1,3 +1,4 @@ +""" Tests for auto auth. """ import json import ddt @@ -20,7 +21,7 @@ class AutoAuthTestCase(UrlResetMixin, TestCase): """ Base class for AutoAuth Tests that properly resets the urls.py """ - URLCONF_MODULES = ['student.urls'] + URLCONF_MODULES = ['openedx.core.djangoapps.user_authn.urls_common', 'openedx.core.djangoapps.user_authn.urls'] @ddt.ddt @@ -208,7 +209,7 @@ def test_redirect_to_course(self, course_id, course_key): else: url_pattern = '/course/{}'.format(unicode(course_key)) - self.assertTrue(response.url.endswith(url_pattern)) # pylint: disable=no-member + self.assertTrue(response.url.endswith(url_pattern)) def test_redirect_to_main(self): # Create user and redirect to 'home' (cms) or 'dashboard' (lms) @@ -224,7 +225,7 @@ def test_redirect_to_main(self): else: url_pattern = '/home' - self.assertTrue(response.url.endswith(url_pattern)) # pylint: disable=no-member + self.assertTrue(response.url.endswith(url_pattern)) def test_redirect_to_specified(self): # Create user and redirect to specified url @@ -235,7 +236,7 @@ def test_redirect_to_specified(self): 'staff': 'true', }, status_code=302) - self.assertTrue(response.url.endswith(url_pattern)) # pylint: disable=no-member + self.assertTrue(response.url.endswith(url_pattern)) def _auto_auth(self, params=None, status_code=200, **kwargs): """ @@ -257,8 +258,8 @@ def _auto_auth(self, params=None, status_code=200, **kwargs): # Check that session and CSRF are set in the response for cookie in ['csrftoken', 'sessionid']: - self.assertIn(cookie, response.cookies) # pylint: disable=maybe-no-member - self.assertTrue(response.cookies[cookie].value) # pylint: disable=maybe-no-member + self.assertIn(cookie, response.cookies) + self.assertTrue(response.cookies[cookie].value) return response diff --git a/common/djangoapps/student/tests/test_login.py b/openedx/core/djangoapps/user_authn/views/tests/test_login.py similarity index 81% rename from common/djangoapps/student/tests/test_login.py rename to openedx/core/djangoapps/user_authn/views/tests/test_login.py index 6258ad83c21a..09bc7682b20f 100644 --- a/common/djangoapps/student/tests/test_login.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_login.py @@ -4,18 +4,15 @@ import json import unittest -import httpretty from django.conf import settings from django.contrib.auth.models import User from django.core.cache import cache from django.urls import NoReverseMatch, reverse from django.http import HttpResponse, HttpResponseBadRequest -from django.test import TestCase from django.test.client import Client from django.test.utils import override_settings from mock import patch from six import text_type -from social_django.models import UserSocialAuth from openedx.core.djangoapps.external_auth.models import ExternalAuthMap from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, waffle @@ -25,19 +22,13 @@ ) from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from student.tests.factories import RegistrationFactory, UserFactory, UserProfileFactory -from student.views import login_oauth_token -from third_party_auth.tests.utils import ( - ThirdPartyOAuthTestMixin, - ThirdPartyOAuthTestMixinFacebook, - ThirdPartyOAuthTestMixinGoogle -) from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory class LoginTest(CacheIsolationTestCase): """ - Test student.views.login_user() view + Test login_user() view """ ENABLED_CACHES = ['default'] @@ -107,7 +98,6 @@ def test_login_fail_no_user_exists(self): response, mock_audit_log = self._login_response( nonexistent_email, 'test_password', - 'student.views.login.AUDIT_LOG' ) self._assert_response(response, success=False, value='Email or password is incorrect') @@ -119,7 +109,6 @@ def test_login_fail_incorrect_email_with_advanced_security(self): response, mock_audit_log = self._login_response( nonexistent_email, 'test_password', - 'student.views.login.AUDIT_LOG' ) self._assert_response(response, success=False, value='Email or password is incorrect') @@ -131,7 +120,6 @@ def test_login_fail_no_user_exists_no_pii(self): response, mock_audit_log = self._login_response( nonexistent_email, 'test_password', - 'student.views.login.AUDIT_LOG' ) self._assert_response(response, success=False, value='Email or password is incorrect') @@ -142,7 +130,6 @@ def test_login_fail_wrong_password(self): response, mock_audit_log = self._login_response( 'test@edx.org', 'wrong_password', - 'student.views.login.AUDIT_LOG' ) self._assert_response(response, success=False, value='Email or password is incorrect') @@ -154,7 +141,6 @@ def test_login_fail_wrong_password_no_pii(self): response, mock_audit_log = self._login_response( 'test@edx.org', 'wrong_password', - 'student.views.login.AUDIT_LOG' ) self._assert_response(response, success=False, value='Email or password is incorrect') @@ -170,7 +156,6 @@ def test_login_not_activated(self): response, mock_audit_log = self._login_response( 'test@edx.org', 'test_password', - 'student.views.login.AUDIT_LOG' ) self._assert_response(response, success=False, value="In order to sign in, you need to activate your account.") @@ -186,7 +171,6 @@ def test_login_not_activated_no_pii(self): response, mock_audit_log = self._login_response( 'test@edx.org', 'test_password', - 'student.views.login.AUDIT_LOG' ) self._assert_response(response, success=False, value="In order to sign in, you need to activate your account.") @@ -198,7 +182,6 @@ def test_login_unicode_email(self): response, mock_audit_log = self._login_response( unicode_email, 'test_password', - 'student.views.login.AUDIT_LOG' ) self._assert_response(response, success=False) self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', unicode_email]) @@ -208,7 +191,6 @@ def test_login_unicode_password(self): response, mock_audit_log = self._login_response( 'test@edx.org', unicode_password, - 'student.views.login.AUDIT_LOG' ) self._assert_response(response, success=False) self._assert_audit_log(mock_audit_log, 'warning', @@ -440,7 +422,8 @@ def test_check_password_policy_compliance(self): """ Tests _enforce_password_policy_compliance succeeds when no exception is thrown """ - with patch('student.views.login.password_policy_compliance.enforce_compliance_on_login') as mock_check_password_policy_compliance: + enforce_compliance_path = 'openedx.core.djangoapps.password_policy.compliance.enforce_compliance_on_login' + with patch(enforce_compliance_path) as mock_check_password_policy_compliance: mock_check_password_policy_compliance.return_value = HttpResponse() response, _ = self._login_response( 'test@edx.org', @@ -454,7 +437,7 @@ def test_check_password_policy_compliance_exception(self): """ Tests _enforce_password_policy_compliance fails with an exception thrown """ - with patch('student.views.login.password_policy_compliance.enforce_compliance_on_login') as \ + with patch('openedx.core.djangoapps.password_policy.compliance.enforce_compliance_on_login') as \ mock_enforce_compliance_on_login: mock_enforce_compliance_on_login.side_effect = NonCompliantPasswordException() response, _ = self._login_response( @@ -469,7 +452,7 @@ def test_check_password_policy_compliance_warning(self): """ Tests _enforce_password_policy_compliance succeeds with a warning thrown """ - with patch('student.views.login.password_policy_compliance.enforce_compliance_on_login') as \ + with patch('openedx.core.djangoapps.password_policy.compliance.enforce_compliance_on_login') as \ mock_enforce_compliance_on_login: mock_enforce_compliance_on_login.side_effect = NonCompliantPasswordWarning('Test warning') response, _ = self._login_response( @@ -480,10 +463,12 @@ def test_check_password_policy_compliance_warning(self): self.assertIn('Test warning', self.client.session['_messages']) self.assertTrue(response_content.get('success')) - def _login_response(self, email, password, patched_audit_log='student.views.AUDIT_LOG', extra_post_params=None): + def _login_response(self, email, password, patched_audit_log=None, extra_post_params=None): """ Post the login info """ + if patched_audit_log is None: + patched_audit_log = 'openedx.core.djangoapps.user_authn.views.login.AUDIT_LOG' post_params = {'email': email, 'password': password} if extra_post_params is not None: post_params.update(extra_post_params) @@ -626,115 +611,3 @@ def test_externalauth_login_required_course_context(self): self.assertEqual(shib_response.redirect_chain[-2], (target_url_shib, 302)) self.assertEqual(shib_response.status_code, 200) - - -@httpretty.activate -class LoginOAuthTokenMixin(ThirdPartyOAuthTestMixin): - """ - Mixin with tests for the login_oauth_token view. A TestCase that includes - this must define the following: - - BACKEND: The name of the backend from python-social-auth - USER_URL: The URL of the endpoint that the backend retrieves user data from - UID_FIELD: The field in the user data that the backend uses as the user id - """ - - def setUp(self): - super(LoginOAuthTokenMixin, self).setUp() - self.url = reverse(login_oauth_token, kwargs={"backend": self.BACKEND}) - - def _assert_error(self, response, status_code, error): - """Assert that the given response was a 400 with the given error code""" - self.assertEqual(response.status_code, status_code) - self.assertEqual(json.loads(response.content), {"error": error}) - - def test_success(self): - self._setup_provider_response(success=True) - response = self.client.post(self.url, {"access_token": "dummy"}) - self.assertEqual(response.status_code, 204) - self.assertEqual(int(self.client.session['_auth_user_id']), self.user.id) - - def test_invalid_token(self): - self._setup_provider_response(success=False) - response = self.client.post(self.url, {"access_token": "dummy"}) - self._assert_error(response, 401, "invalid_token") - - def test_missing_token(self): - response = self.client.post(self.url) - self._assert_error(response, 400, "invalid_request") - - def test_unlinked_user(self): - UserSocialAuth.objects.all().delete() - self._setup_provider_response(success=True) - response = self.client.post(self.url, {"access_token": "dummy"}) - self._assert_error(response, 401, "invalid_token") - - def test_get_method(self): - response = self.client.get(self.url, {"access_token": "dummy"}) - self.assertEqual(response.status_code, 405) - - -# This is necessary because cms does not implement third party auth -@unittest.skipUnless(settings.FEATURES.get("ENABLE_THIRD_PARTY_AUTH"), "third party auth not enabled") -class LoginOAuthTokenTestFacebook(LoginOAuthTokenMixin, ThirdPartyOAuthTestMixinFacebook, TestCase): - """Tests login_oauth_token with the Facebook backend""" - pass - - -# This is necessary because cms does not implement third party auth -@unittest.skipUnless(settings.FEATURES.get("ENABLE_THIRD_PARTY_AUTH"), "third party auth not enabled") -class LoginOAuthTokenTestGoogle(LoginOAuthTokenMixin, ThirdPartyOAuthTestMixinGoogle, TestCase): - """Tests login_oauth_token with the Google backend""" - pass - - -class TestPasswordVerificationView(CacheIsolationTestCase): - """ - Test the password verification endpoint. - """ - def setUp(self): - super(TestPasswordVerificationView, self).setUp() - self.user = UserFactory.build(username='test_user', is_active=True) - self.password = 'test_password' - self.user.set_password(self.password) - self.user.save() - # Create a registration for the user - RegistrationFactory(user=self.user) - - # Create a profile for the user - UserProfileFactory(user=self.user) - - # Create the test client - self.client = Client() - cache.clear() - self.url = reverse('verify_password') - - def test_password_logged_in_valid(self): - success = self.client.login(username=self.user.username, password=self.password) - assert success - response = self.client.post(self.url, {'password': self.password}) - assert response.status_code == 200 - - def test_password_logged_in_invalid(self): - success = self.client.login(username=self.user.username, password=self.password) - assert success - response = self.client.post(self.url, {'password': 'wrong_password'}) - assert response.status_code == 403 - - def test_password_logged_out(self): - response = self.client.post(self.url, {'username': self.user.username, 'password': self.password}) - assert response.status_code == 302 - - @patch.dict("django.conf.settings.FEATURES", {'ENABLE_MAX_FAILED_LOGIN_ATTEMPTS': True}) - @override_settings(MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS=6000) - def test_locked_out(self): - success = self.client.login(username=self.user.username, password=self.password) - assert success - # Attempt a password check greater than the number of allowed times. - for _ in xrange(settings.MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED + 1): - self.client.post(self.url, {'password': 'wrong_password'}) - - response = self.client.post(self.url, {'password': self.password}) - assert response.status_code == 403 - assert response.content == ('This account has been temporarily locked due ' - 'to excessive login failures. Try again later.') diff --git a/common/djangoapps/student/tests/test_login_registration_forms.py b/openedx/core/djangoapps/user_authn/views/tests/test_login_registration_forms.py similarity index 97% rename from common/djangoapps/student/tests/test_login_registration_forms.py rename to openedx/core/djangoapps/user_authn/views/tests/test_login_registration_forms.py index 842525af1b8c..e3db74fbb44f 100644 --- a/common/djangoapps/student/tests/test_login_registration_forms.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_login_registration_forms.py @@ -40,7 +40,7 @@ def _finish_auth_url(params): class LoginFormTest(ThirdPartyAuthTestMixin, UrlResetMixin, SharedModuleStoreTestCase): """Test rendering of the login form. """ - URLCONF_MODULES = ['lms.urls'] + URLCONF_MODULES = ['openedx.core.djangoapps.user_authn.urls'] @classmethod def setUpClass(cls): @@ -48,7 +48,7 @@ def setUpClass(cls): cls.course = CourseFactory.create() @patch.dict(settings.FEATURES, {"ENABLE_COMBINED_LOGIN_REGISTRATION": False}) - def setUp(self): + def setUp(self): # pylint: disable=arguments-differ super(LoginFormTest, self).setUp() self.url = reverse("signin_user") @@ -157,7 +157,7 @@ def test_params(self, opt_in_value): class RegisterFormTest(ThirdPartyAuthTestMixin, UrlResetMixin, SharedModuleStoreTestCase): """Test rendering of the registration form. """ - URLCONF_MODULES = ['lms.urls'] + URLCONF_MODULES = ['openedx.core.djangoapps.user_authn.urls'] @classmethod def setUpClass(cls): @@ -165,7 +165,7 @@ def setUpClass(cls): cls.course = CourseFactory.create() @patch.dict(settings.FEATURES, {"ENABLE_COMBINED_LOGIN_REGISTRATION": False}) - def setUp(self): + def setUp(self): # pylint: disable=arguments-differ super(RegisterFormTest, self).setUp() self.url = reverse("register_user") diff --git a/common/djangoapps/student/tests/test_create_account.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py similarity index 98% rename from common/djangoapps/student/tests/test_create_account.py rename to openedx/core/djangoapps/user_authn/views/tests/test_register.py index a1c235ecba74..7e4e166ad1c9 100644 --- a/common/djangoapps/student/tests/test_create_account.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -17,6 +17,11 @@ from django_comment_common.models import ForumsConfig from notification_prefs import NOTIFICATION_PREF_KEY +from openedx.core.djangoapps.user_authn.views.deprecated import create_account +from openedx.core.djangoapps.user_authn.views.register import ( + REGISTRATION_AFFILIATE_ID, REGISTRATION_UTM_CREATED_AT, REGISTRATION_UTM_PARAMETERS, + _skip_activation_email, +) from openedx.core.djangoapps.external_auth.models import ExternalAuthMap from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin @@ -26,8 +31,6 @@ from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, waffle from openedx.core.djangoapps.user_api.preferences.api import get_user_preference from student.models import UserAttribute -from student.views import REGISTRATION_AFFILIATE_ID, REGISTRATION_UTM_CREATED_AT, REGISTRATION_UTM_PARAMETERS, \ - create_account, skip_activation_email from student.tests.factories import UserFactory from third_party_auth.tests import factories as third_party_auth_factory @@ -160,8 +163,8 @@ def test_profile_saved_no_optional_fields(self): "Microsites not implemented in this environment" ) @override_settings(LMS_SEGMENT_KEY="testkey") - @mock.patch('student.views.analytics.track') - @mock.patch('student.views.analytics.identify') + @mock.patch('openedx.core.djangoapps.user_authn.views.register.analytics.track') + @mock.patch('openedx.core.djangoapps.user_authn.views.register.analytics.identify') def test_segment_tracking(self, mock_segment_identify, _): year = datetime.now().year year_of_birth = year - 14 @@ -541,7 +544,7 @@ def test_should_skip_activation_email( user = UserFactory(username=TEST_USERNAME, email=TEST_EMAIL) with override_settings(FEATURES=dict(settings.FEATURES, **feature_overrides)): - result = skip_activation_email( + result = _skip_activation_email( user=user, do_external_auth=do_external_auth, running_pipeline=running_pipeline, @@ -826,6 +829,7 @@ def assert_extra_field_error(): @mock.patch("lms.lib.comment_client.User.base_url", TEST_CS_URL) @mock.patch("lms.lib.comment_client.utils.requests.request", return_value=mock.Mock(status_code=200, text='{}')) class TestCreateCommentsServiceUser(TransactionTestCase): + """ Tests for creating comments service user. """ def setUp(self): super(TestCreateCommentsServiceUser, self).setUp() @@ -859,7 +863,7 @@ def test_cs_user_not_created(self, register, request): "If user account creation fails, we should not create a comments service user" try: self.client.post(self.url, self.params) - except: + except: # pylint: disable=bare-except pass with self.assertRaises(User.DoesNotExist): User.objects.get(username=self.username) diff --git a/lms/djangoapps/student_account/test/test_views.py b/openedx/core/djangoapps/user_authn/views/tests/test_views.py similarity index 78% rename from lms/djangoapps/student_account/test/test_views.py rename to openedx/core/djangoapps/user_authn/views/tests/test_views.py index ff507035358c..6cf9cec630a9 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_views.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- -""" Tests for student account views. """ +""" Tests for user authn views. """ +from http.cookies import SimpleCookie import logging import re from unittest import skipUnless @@ -17,14 +18,11 @@ from django.core import mail from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse -from django.http import HttpRequest from django.test import TestCase from django.test.client import RequestFactory from django.test.utils import override_settings from django.utils.translation import ugettext as _ from edx_oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory, RefreshTokenFactory -from edx_rest_api_client import exceptions -from http.cookies import SimpleCookie from oauth2_provider.models import AccessToken as dot_access_token from oauth2_provider.models import RefreshToken as dot_refresh_token from provider.oauth2.models import AccessToken as dop_access_token @@ -32,25 +30,18 @@ from testfixtures import LogCapture from course_modes.models import CourseMode -from lms.djangoapps.commerce.models import CommerceConfiguration -from lms.djangoapps.commerce.tests import factories -from lms.djangoapps.commerce.tests.mocks import mock_get_orders -from lms.djangoapps.student_account.views import login_and_registration_form +from openedx.core.djangoapps.user_authn.views.login_form import login_and_registration_form from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factories -from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin -from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme_context from openedx.core.djangoapps.user_api.accounts.api import activate_account, create_account +from openedx.core.djangoapps.user_api.errors import UserAPIInternalError from openedx.core.djangolib.js_utils import dump_js_escaped_json from openedx.core.djangolib.markup import HTML, Text -from openedx.core.djangolib.testing.utils import CacheIsolationTestCase -from student.tests.factories import UserFactory -from student_account.views import account_settings_context, get_user_orders +from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms from third_party_auth.tests.testutil import ThirdPartyAuthTestMixin, simulate_running_pipeline from util.testing import UrlResetMixin from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from openedx.core.djangoapps.user_api.errors import UserAPIInternalError LOGGER_NAME = 'audit' User = get_user_model() # pylint:disable=invalid-name @@ -59,9 +50,10 @@ FEATURES_WITH_FAILED_PASSWORD_RESET_EMAIL['ENABLE_PASSWORD_RESET_FAILURE_EMAIL'] = True +@skip_unless_lms @ddt.ddt -class StudentAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin): - """ Tests for the student account views that update the user's account information. """ +class UserAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin): + """ Tests for views that update the user's account information. """ USERNAME = u"heisenberg" ALTERNATE_USERNAME = u"walt" @@ -78,7 +70,7 @@ class StudentAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin): ENABLED_CACHES = ['default'] def setUp(self): - super(StudentAccountUpdateTest, self).setUp() + super(UserAccountUpdateTest, self).setUp() # Create/activate a new account activation_key = create_account(self.USERNAME, self.OLD_PASSWORD, self.OLD_EMAIL) @@ -293,8 +285,9 @@ def assert_access_token_destroyed(self, user): self.assertFalse(dop_refresh_token.objects.filter(user=user).exists()) +@skip_unless_lms @ddt.ddt -class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMixin, ModuleStoreTestCase): +class LoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMixin, ModuleStoreTestCase): """ Tests for the student account views that update the user's account information. """ shard = 7 USERNAME = "bob" @@ -304,8 +297,8 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi URLCONF_MODULES = ['openedx.core.djangoapps.embargo'] @mock.patch.dict(settings.FEATURES, {'EMBARGO': True}) - def setUp(self): - super(StudentAccountLoginAndRegistrationTest, self).setUp() + def setUp(self): # pylint: disable=arguments-differ + super(LoginAndRegistrationTest, self).setUp() # Several third party auth providers are created for these tests: self.google_provider = self.configure_google_provider(enabled=True, visible=True) @@ -387,7 +380,7 @@ def test_third_party_auth_disabled(self, url_name): response = self.client.get(reverse(url_name)) self._assert_third_party_auth_data(response, None, None, [], None) - @mock.patch('student_account.views.enterprise_customer_for_request') + @mock.patch('openedx.core.djangoapps.user_authn.views.login_form.enterprise_customer_for_request') @mock.patch('openedx.core.djangoapps.user_api.api.enterprise_customer_for_request') @ddt.data( ("signin_user", None, None, None, False), @@ -448,7 +441,7 @@ def test_third_party_auth( # Simulate a running pipeline if current_backend is not None: - pipeline_target = "student_account.views.third_party_auth.pipeline" + pipeline_target = "openedx.core.djangoapps.user_authn.views.login_form.third_party_auth.pipeline" with simulate_running_pipeline(pipeline_target, current_backend, email=email): response = self.client.get(reverse(url_name), params, HTTP_ACCEPT="text/html") @@ -509,7 +502,7 @@ def _configure_testshib_provider(self, provider_name, idp_slug): self.configure_saml_provider(**kwargs) @mock.patch('django.conf.settings.MESSAGE_STORAGE', 'django.contrib.messages.storage.cookie.CookieStorage') - @mock.patch('lms.djangoapps.student_account.views.enterprise_customer_for_request') + @mock.patch('openedx.core.djangoapps.user_authn.views.login_form.enterprise_customer_for_request') @ddt.data( ( 'signin_user', @@ -552,7 +545,7 @@ def test_saml_auth_with_error( 'idp_name': dummy_idp } } - pipeline_target = 'student_account.views.third_party_auth.pipeline' + pipeline_target = 'openedx.core.djangoapps.user_authn.views.login_form.third_party_auth.pipeline' with simulate_running_pipeline(pipeline_target, current_backend, **pipeline_response): with mock.patch('edxmako.request_context.get_current_request', return_value=request): response = login_and_registration_form(request) @@ -653,7 +646,7 @@ def test_settings_tpa_hinted_login_dialog_disabled(self, url_name, auth_entry): target_status_code=302 ) - @mock.patch('student_account.views.enterprise_customer_for_request') + @mock.patch('openedx.core.djangoapps.user_authn.views.login_form.enterprise_customer_for_request') @ddt.data( ('signin_user', False, None, None), ('register_user', False, None, None), @@ -850,214 +843,7 @@ def test_browser_language_dialent(self): self.assertEqual(response['Content-Language'], 'es-es') -class AccountSettingsViewTest(ThirdPartyAuthTestMixin, TestCase, ProgramsApiConfigMixin): - """ Tests for the account settings view. """ - - USERNAME = 'student' - PASSWORD = 'password' - FIELDS = [ - 'country', - 'gender', - 'language', - 'level_of_education', - 'password', - 'year_of_birth', - 'preferred_language', - 'time_zone', - ] - - @mock.patch("django.conf.settings.MESSAGE_STORAGE", 'django.contrib.messages.storage.cookie.CookieStorage') - def setUp(self): - super(AccountSettingsViewTest, self).setUp() - self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD) - CommerceConfiguration.objects.create(cache_ttl=10, enabled=True) - self.client.login(username=self.USERNAME, password=self.PASSWORD) - - self.request = HttpRequest() - self.request.user = self.user - - # For these tests, two third party auth providers are enabled by default: - self.configure_google_provider(enabled=True, visible=True) - self.configure_facebook_provider(enabled=True, visible=True) - - # Python-social saves auth failure notifcations in Django messages. - # See pipeline.get_duplicate_provider() for details. - self.request.COOKIES = {} - MessageMiddleware().process_request(self.request) - messages.error(self.request, 'Facebook is already in use.', extra_tags='Auth facebook') - - @mock.patch('openedx.features.enterprise_support.api.get_enterprise_customer_for_learner') - def test_context(self, mock_get_enterprise_customer_for_learner): - self.request.site = SiteFactory.create() - mock_get_enterprise_customer_for_learner.return_value = {} - context = account_settings_context(self.request) - - user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username}) - self.assertEqual(context['user_accounts_api_url'], user_accounts_api_url) - - user_preferences_api_url = reverse('preferences_api', kwargs={'username': self.user.username}) - self.assertEqual(context['user_preferences_api_url'], user_preferences_api_url) - - for attribute in self.FIELDS: - self.assertIn(attribute, context['fields']) - - self.assertEqual( - context['user_accounts_api_url'], reverse("accounts_api", kwargs={'username': self.user.username}) - ) - self.assertEqual( - context['user_preferences_api_url'], reverse('preferences_api', kwargs={'username': self.user.username}) - ) - - self.assertEqual(context['duplicate_provider'], 'facebook') - self.assertEqual(context['auth']['providers'][0]['name'], 'Facebook') - self.assertEqual(context['auth']['providers'][1]['name'], 'Google') - - self.assertEqual(context['sync_learner_profile_data'], False) - self.assertEqual(context['edx_support_url'], settings.SUPPORT_SITE_LINK) - self.assertEqual(context['enterprise_name'], None) - self.assertEqual( - context['enterprise_readonly_account_fields'], {'fields': settings.ENTERPRISE_READONLY_ACCOUNT_FIELDS} - ) - - @mock.patch('student_account.views.get_enterprise_customer_for_learner') - @mock.patch('openedx.features.enterprise_support.utils.third_party_auth.provider.Registry.get') - def test_context_for_enterprise_learner( - self, mock_get_auth_provider, mock_get_enterprise_customer_for_learner - ): - dummy_enterprise_customer = { - 'uuid': 'real-ent-uuid', - 'name': 'Dummy Enterprise', - 'identity_provider': 'saml-ubc' - } - mock_get_enterprise_customer_for_learner.return_value = dummy_enterprise_customer - self.request.site = SiteFactory.create() - mock_get_auth_provider.return_value.sync_learner_profile_data = True - context = account_settings_context(self.request) - - user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username}) - self.assertEqual(context['user_accounts_api_url'], user_accounts_api_url) - - user_preferences_api_url = reverse('preferences_api', kwargs={'username': self.user.username}) - self.assertEqual(context['user_preferences_api_url'], user_preferences_api_url) - - for attribute in self.FIELDS: - self.assertIn(attribute, context['fields']) - - self.assertEqual( - context['user_accounts_api_url'], reverse("accounts_api", kwargs={'username': self.user.username}) - ) - self.assertEqual( - context['user_preferences_api_url'], reverse('preferences_api', kwargs={'username': self.user.username}) - ) - - self.assertEqual(context['duplicate_provider'], 'facebook') - self.assertEqual(context['auth']['providers'][0]['name'], 'Facebook') - self.assertEqual(context['auth']['providers'][1]['name'], 'Google') - - self.assertEqual( - context['sync_learner_profile_data'], mock_get_auth_provider.return_value.sync_learner_profile_data - ) - self.assertEqual(context['edx_support_url'], settings.SUPPORT_SITE_LINK) - self.assertEqual(context['enterprise_name'], dummy_enterprise_customer['name']) - self.assertEqual( - context['enterprise_readonly_account_fields'], {'fields': settings.ENTERPRISE_READONLY_ACCOUNT_FIELDS} - ) - - def test_view(self): - """ - Test that all fields are visible - """ - view_path = reverse('account_settings') - response = self.client.get(path=view_path) - - for attribute in self.FIELDS: - self.assertIn(attribute, response.content) - - def test_header_with_programs_listing_enabled(self): - """ - Verify that tabs header will be shown while program listing is enabled. - """ - self.create_programs_config() - view_path = reverse('account_settings') - response = self.client.get(path=view_path) - - self.assertContains(response, 'global-header') - - def test_header_with_programs_listing_disabled(self): - """ - Verify that nav header will be shown while program listing is disabled. - """ - self.create_programs_config(enabled=False) - view_path = reverse('account_settings') - response = self.client.get(path=view_path) - - self.assertContains(response, 'global-header') - - def test_commerce_order_detail(self): - """ - Verify that get_user_orders returns the correct order data. - """ - with mock_get_orders(): - order_detail = get_user_orders(self.user) - - for i, order in enumerate(mock_get_orders.default_response['results']): - expected = { - 'number': order['number'], - 'price': order['total_excl_tax'], - 'order_date': 'Jan 01, 2016', - 'receipt_url': '/checkout/receipt/?order_number=' + order['number'], - 'lines': order['lines'], - } - self.assertEqual(order_detail[i], expected) - - def test_commerce_order_detail_exception(self): - with mock_get_orders(exception=exceptions.HttpNotFoundError): - order_detail = get_user_orders(self.user) - - self.assertEqual(order_detail, []) - - def test_incomplete_order_detail(self): - response = { - 'results': [ - factories.OrderFactory( - status='Incomplete', - lines=[ - factories.OrderLineFactory( - product=factories.ProductFactory(attribute_values=[factories.ProductAttributeFactory()]) - ) - ] - ) - ] - } - with mock_get_orders(response=response): - order_detail = get_user_orders(self.user) - - self.assertEqual(order_detail, []) - - def test_order_history_with_no_product(self): - response = { - 'results': [ - factories.OrderFactory( - lines=[ - factories.OrderLineFactory( - product=None - ), - factories.OrderLineFactory( - product=factories.ProductFactory(attribute_values=[factories.ProductAttributeFactory( - name='certificate_type', - value='verified' - )]) - ) - ] - ) - ] - } - with mock_get_orders(response=response): - order_detail = get_user_orders(self.user) - - self.assertEqual(len(order_detail), 1) - - +@skip_unless_lms @override_settings(SITE_NAME=settings.MICROSITE_LOGISTRATION_HOSTNAME) class MicrositeLogistrationTests(TestCase): """ @@ -1115,6 +901,7 @@ def test_no_override(self): self.assertNotIn('