Merge pull request #18917 from edx/arch/user-authn-app
Consolidate user login and authentication code
This commit is contained in:
@@ -32,6 +32,7 @@ from lms.envs.test import (
|
||||
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.
|
||||
|
||||
@@ -33,6 +33,7 @@ COURSELIKE_KEY_PATTERN = r'(?P<course_key_string>({}|{}))'.format(
|
||||
LIBRARY_KEY_PATTERN = r'(?P<library_key_string>library-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'),
|
||||
|
||||
@@ -17,10 +17,6 @@ from django.contrib.auth.models import User
|
||||
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
|
||||
|
||||
@@ -10,7 +10,7 @@ from six import text_type
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ class ActivationEmailTests(CacheIsolationTestCase):
|
||||
)
|
||||
|
||||
|
||||
@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):
|
||||
"""
|
||||
|
||||
@@ -15,7 +15,7 @@ from mock import patch
|
||||
|
||||
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})
|
||||
|
||||
@@ -29,7 +29,7 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOvervi
|
||||
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
|
||||
|
||||
@@ -9,19 +9,9 @@ from django.contrib.auth.views import password_reset_complete
|
||||
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<error>[^/]*)$', views.login_user),
|
||||
|
||||
url(r'^email_confirm/(?P<key>[^/]*)$', views.confirm_email_change, name='confirm_email_change'),
|
||||
|
||||
url(r'^create_account$', views.create_account, name='create_account'),
|
||||
url(r'^activate/(?P<key>[^/]*)$', views.activate_account, name="activate"),
|
||||
|
||||
url(r'^accounts/disable_account_ajax$', views.disable_account_ajax, name="disable_account_ajax"),
|
||||
@@ -31,6 +21,7 @@ urlpatterns = [
|
||||
url(r'^change_email_settings$', views.change_email_settings, name='change_email_settings'),
|
||||
|
||||
# password reset in views (see below for password reset django views)
|
||||
url(r'^account/password$', views.password_change_request_handler, name='password_change_request'),
|
||||
url(r'^password_reset/$', views.password_reset, name='password_reset'),
|
||||
url(
|
||||
r'^password_reset_confirm/(?P<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/$',
|
||||
@@ -38,19 +29,11 @@ urlpatterns = [
|
||||
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
|
||||
|
||||
@@ -4,5 +4,4 @@ Combines all of the broken out student views
|
||||
|
||||
# pylint: disable=wildcard-import
|
||||
from dashboard import *
|
||||
from login import *
|
||||
from management import *
|
||||
|
||||
@@ -44,7 +44,7 @@ from openedx.features.enterprise_support.api import get_dashboard_consent_notifi
|
||||
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,
|
||||
|
||||
@@ -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 += "<br/><br/>"
|
||||
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 += "<br/><br/>"
|
||||
message += _(
|
||||
"If you don't have an {platform_name} account yet, "
|
||||
"click <strong>Register</strong> 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.<br /><br />'
|
||||
'We just sent an activation link to <strong>{email}</strong>. If '
|
||||
'you do not receive an email, check your spam folders or '
|
||||
'<a href="{support_url}">contact {platform} Support</a>.')
|
||||
|
||||
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 "<unknown>"
|
||||
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
|
||||
@@ -3,19 +3,14 @@ Student Views
|
||||
"""
|
||||
|
||||
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.context_processors import csrf
|
||||
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.models import (
|
||||
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):
|
||||
|
||||
@@ -263,8 +263,8 @@ class ProviderConfig(ConfigurationModel):
|
||||
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:
|
||||
|
||||
@@ -78,13 +78,13 @@ from social_core.exceptions import AuthException
|
||||
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
|
||||
|
||||
@@ -20,11 +20,12 @@ from social_django import utils as social_utils
|
||||
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 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
|
||||
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 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
|
||||
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 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
|
||||
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 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
|
||||
# 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 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
|
||||
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 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
|
||||
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 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
|
||||
# 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 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
|
||||
# ...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 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
|
||||
|
||||
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 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
|
||||
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 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
|
||||
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.
|
||||
|
||||
@@ -18,9 +18,10 @@ from social_django.models import UserSocialAuth
|
||||
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 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin
|
||||
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)
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ import third_party_auth
|
||||
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
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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
|
||||
27
lms/urls.py
27
lms/urls.py
@@ -50,7 +50,6 @@ from ratelimitbackend import admin
|
||||
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 @@ urlpatterns = [
|
||||
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 @@ urlpatterns += [
|
||||
name='lti_rest_endpoints',
|
||||
),
|
||||
|
||||
# Student account
|
||||
url(
|
||||
r'^account/',
|
||||
include('student_account.urls')
|
||||
),
|
||||
|
||||
# Student Notes
|
||||
url(
|
||||
r'^courses/{}/edxnotes/'.format(
|
||||
@@ -958,9 +934,6 @@ if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
|
||||
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<backend>[^/]+)/$', student_views.login_oauth_token),
|
||||
]
|
||||
|
||||
# Enterprise
|
||||
|
||||
@@ -316,7 +316,7 @@ class ShibSPTest(CacheIsolationTestCase):
|
||||
'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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
15
openedx/core/djangoapps/oauth_dispatch/api.py
Normal file
15
openedx/core/djangoapps/oauth_dispatch/api.py
Normal file
@@ -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()
|
||||
241
openedx/core/djangoapps/user_api/accounts/settings_views.py
Normal file
241
openedx/core/djangoapps/user_api/accounts/settings_views.py
Normal file
@@ -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
|
||||
@@ -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)
|
||||
@@ -33,6 +33,7 @@ from wiki.models import ArticleRevision
|
||||
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 @@ from student.models import (
|
||||
PasswordHistory,
|
||||
PendingNameChange,
|
||||
CourseEnrollmentAllowed,
|
||||
LoginFailures,
|
||||
PendingEmailChange,
|
||||
Registration,
|
||||
User,
|
||||
@@ -60,8 +62,6 @@ from student.models import (
|
||||
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,
|
||||
|
||||
@@ -9,9 +9,6 @@ from django.utils.translation import ugettext as _
|
||||
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 @@ from six import text_type
|
||||
|
||||
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.helpers import require_post_params, shim_s
|
||||
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 @@ class LoginSessionView(APIView):
|
||||
"""
|
||||
# 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"))
|
||||
|
||||
24
openedx/core/djangoapps/user_authn/apps.py
Normal file
24
openedx/core/djangoapps/user_authn/apps.py
Normal file
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
22
openedx/core/djangoapps/user_authn/exceptions.py
Normal file
22
openedx/core/djangoapps/user_authn/exceptions.py
Normal file
@@ -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
|
||||
@@ -6,8 +6,8 @@ from django.conf import settings
|
||||
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
|
||||
32
openedx/core/djangoapps/user_authn/urls.py
Normal file
32
openedx/core/djangoapps/user_authn/urls.py
Normal file
@@ -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'),
|
||||
]
|
||||
29
openedx/core/djangoapps/user_authn/urls_common.py
Normal file
29
openedx/core/djangoapps/user_authn/urls_common.py
Normal file
@@ -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<error>[^/]*)$', 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),
|
||||
]
|
||||
202
openedx/core/djangoapps/user_authn/views/auto_auth.py
Normal file
202
openedx/core/djangoapps/user_authn/views/auto_auth.py
Normal file
@@ -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')
|
||||
162
openedx/core/djangoapps/user_authn/views/deprecated.py
Normal file
162
openedx/core/djangoapps/user_authn/views/deprecated.py
Normal file
@@ -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
|
||||
395
openedx/core/djangoapps/user_authn/views/login.py
Normal file
395
openedx/core/djangoapps/user_authn/views/login.py
Normal file
@@ -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 += "<br/><br/>"
|
||||
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 += "<br/><br/>"
|
||||
message += _(
|
||||
"If you don't have an {platform_name} account yet, "
|
||||
"click <strong>Register</strong> 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.<br /><br />'
|
||||
'We just sent an activation link to <strong>{email}</strong>. If '
|
||||
'you do not receive an email, check your spam folders or '
|
||||
'<a href="{support_url}">contact {platform} Support</a>.')
|
||||
|
||||
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 "<unknown>"
|
||||
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())
|
||||
259
openedx/core/djangoapps/user_authn/views/login_form.py
Normal file
259
openedx/core/djangoapps/user_authn/views/login_form.py
Normal file
@@ -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)
|
||||
101
openedx/core/djangoapps/user_authn/views/logout.py
Normal file
101
openedx/core/djangoapps/user_authn/views/logout.py
Normal file
@@ -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
|
||||
467
openedx/core/djangoapps/user_authn/views/register.py
Normal file
467
openedx/core/djangoapps/user_authn/views/register.py
Normal file
@@ -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
|
||||
)
|
||||
@@ -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 @@ class AutoAuthEnabledTestCase(AutoAuthTestCase):
|
||||
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 @@ class AutoAuthEnabledTestCase(AutoAuthTestCase):
|
||||
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 @@ class AutoAuthEnabledTestCase(AutoAuthTestCase):
|
||||
'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 @@ class AutoAuthEnabledTestCase(AutoAuthTestCase):
|
||||
|
||||
# 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
|
||||
|
||||
@@ -4,18 +4,15 @@ Tests for student activation and login
|
||||
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.djangoapps.password_policy.compliance import (
|
||||
)
|
||||
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 @@ class LoginTest(CacheIsolationTestCase):
|
||||
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 @@ class LoginTest(CacheIsolationTestCase):
|
||||
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 @@ class LoginTest(CacheIsolationTestCase):
|
||||
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 @@ class LoginTest(CacheIsolationTestCase):
|
||||
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 @@ class LoginTest(CacheIsolationTestCase):
|
||||
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 @@ class LoginTest(CacheIsolationTestCase):
|
||||
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 @@ class LoginTest(CacheIsolationTestCase):
|
||||
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 @@ class LoginTest(CacheIsolationTestCase):
|
||||
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 @@ class LoginTest(CacheIsolationTestCase):
|
||||
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 @@ class LoginTest(CacheIsolationTestCase):
|
||||
"""
|
||||
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 @@ class LoginTest(CacheIsolationTestCase):
|
||||
"""
|
||||
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 @@ class LoginTest(CacheIsolationTestCase):
|
||||
"""
|
||||
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 @@ class LoginTest(CacheIsolationTestCase):
|
||||
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 @@ class ExternalAuthShibTest(ModuleStoreTestCase):
|
||||
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.')
|
||||
@@ -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 @@ class LoginFormTest(ThirdPartyAuthTestMixin, UrlResetMixin, SharedModuleStoreTes
|
||||
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 @@ class LoginFormTest(ThirdPartyAuthTestMixin, UrlResetMixin, SharedModuleStoreTes
|
||||
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 @@ class RegisterFormTest(ThirdPartyAuthTestMixin, UrlResetMixin, SharedModuleStore
|
||||
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")
|
||||
@@ -17,6 +17,11 @@ from django.test.utils import override_settings
|
||||
|
||||
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.accounts import (
|
||||
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 @@ class TestCreateAccount(SiteMixin, TestCase):
|
||||
"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 @@ class TestCreateAccount(SiteMixin, TestCase):
|
||||
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 @@ class TestCreateAccountValidation(TestCase):
|
||||
@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 @@ class TestCreateCommentsServiceUser(TransactionTestCase):
|
||||
"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)
|
||||
@@ -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.contrib.sessions.middleware import SessionMiddleware
|
||||
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 provider.oauth2.models import RefreshToken as dop_refresh_token
|
||||
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 = settings.FEATURES.copy()
|
||||
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 @@ class StudentAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin):
|
||||
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 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
|
||||
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 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
|
||||
|
||||
# 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 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
|
||||
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 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
|
||||
'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 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
|
||||
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 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
|
||||
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 @@ class MicrositeLogistrationTests(TestCase):
|
||||
self.assertNotIn('<div id="login-and-registration-container"', resp.content)
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class AccountCreationTestCaseWithSiteOverrides(SiteMixin, TestCase):
|
||||
"""
|
||||
Test cases for Feature flag ALLOW_PUBLIC_ACCOUNT_CREATION which when
|
||||
@@ -8,8 +8,8 @@ from django.utils.translation import ugettext as _
|
||||
|
||||
import third_party_auth
|
||||
from third_party_auth import pipeline
|
||||
from student.cookies import set_experiments_is_enterprise_cookie
|
||||
|
||||
from openedx.core.djangoapps.user_authn.cookies import set_experiments_is_enterprise_cookie
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
|
||||
|
||||
4
setup.py
4
setup.py
@@ -6,7 +6,7 @@ from setuptools import setup
|
||||
|
||||
setup(
|
||||
name="Open edX",
|
||||
version="0.10",
|
||||
version="0.11",
|
||||
install_requires=["setuptools"],
|
||||
requires=[],
|
||||
# NOTE: These are not the names we should be installing. This tree should
|
||||
@@ -77,6 +77,7 @@ setup(
|
||||
"zendesk_proxy = openedx.core.djangoapps.zendesk_proxy.apps:ZendeskProxyConfig",
|
||||
"instructor = lms.djangoapps.instructor.apps:InstructorConfig",
|
||||
"password_policy = openedx.core.djangoapps.password_policy.apps:PasswordPolicyConfig",
|
||||
"user_authn = openedx.core.djangoapps.user_authn.apps:UserAuthnConfig"
|
||||
],
|
||||
"cms.djangoapp": [
|
||||
"ace_common = openedx.core.djangoapps.ace_common.apps:AceCommonConfig",
|
||||
@@ -92,6 +93,7 @@ setup(
|
||||
"bookmarks = openedx.core.djangoapps.bookmarks.apps:BookmarksConfig",
|
||||
"zendesk_proxy = openedx.core.djangoapps.zendesk_proxy.apps:ZendeskProxyConfig",
|
||||
"password_policy = openedx.core.djangoapps.password_policy.apps:PasswordPolicyConfig",
|
||||
"user_authn = openedx.core.djangoapps.user_authn.apps:UserAuthnConfig"
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user