Merge pull request #18917 from edx/arch/user-authn-app

Consolidate user login and authentication code
This commit is contained in:
Nimisha Asthagiri
2018-09-15 10:00:51 -04:00
committed by GitHub
50 changed files with 2421 additions and 2416 deletions

View File

@@ -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.

View File

@@ -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'),

View File

@@ -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

View File

@@ -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

View File

@@ -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):
"""

View File

@@ -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})

View File

@@ -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

View File

@@ -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

View File

@@ -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 *

View File

@@ -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,

View File

@@ -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

View File

@@ -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):

View File

@@ -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:

View File

@@ -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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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'),
]

View File

@@ -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

View File

@@ -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

View File

@@ -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')

View File

@@ -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)
# -----------------------------------------------------------------------------

View 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()

View 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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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"))

View 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',
},
},
}

View 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

View File

@@ -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

View 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'),
]

View 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),
]

View 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')

View 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

View 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())

View 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)

View 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

View 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
)

View File

@@ -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

View File

@@ -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.')

View File

@@ -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")

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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"
],
}
)