Merge pull request #8262 from open-craft/tpa-pipeline-consolidation
Cleanup of third-party login and auto-enrollment
This commit is contained in:
@@ -10,6 +10,7 @@ from provider.oauth2.forms import ScopeChoiceField, ScopeMixin
|
||||
from provider.oauth2.models import Client
|
||||
from requests import HTTPError
|
||||
from social.backends import oauth as social_oauth
|
||||
from social.exceptions import AuthException
|
||||
|
||||
from third_party_auth import pipeline
|
||||
|
||||
@@ -54,7 +55,7 @@ class AccessTokenExchangeForm(ScopeMixin, OAuthForm):
|
||||
if self._errors:
|
||||
return {}
|
||||
|
||||
backend = self.request.social_strategy.backend
|
||||
backend = self.request.backend
|
||||
if not isinstance(backend, social_oauth.BaseOAuth2):
|
||||
raise OAuthValidationError(
|
||||
{
|
||||
@@ -88,8 +89,8 @@ class AccessTokenExchangeForm(ScopeMixin, OAuthForm):
|
||||
|
||||
user = None
|
||||
try:
|
||||
user = backend.do_auth(self.cleaned_data.get("access_token"))
|
||||
except HTTPError:
|
||||
user = backend.do_auth(self.cleaned_data.get("access_token"), allow_inactive_user=True)
|
||||
except (HTTPError, AuthException):
|
||||
pass
|
||||
if user and isinstance(user, User):
|
||||
self.cleaned_data["user"] = user
|
||||
|
||||
@@ -24,8 +24,11 @@ class AccessTokenExchangeFormTest(AccessTokenExchangeTestMixin):
|
||||
def setUp(self):
|
||||
super(AccessTokenExchangeFormTest, self).setUp()
|
||||
self.request = RequestFactory().post("dummy_url")
|
||||
redirect_uri = 'dummy_redirect_url'
|
||||
SessionMiddleware().process_request(self.request)
|
||||
self.request.social_strategy = social_utils.load_strategy(self.request, self.BACKEND)
|
||||
self.request.social_strategy = social_utils.load_strategy(self.request)
|
||||
# pylint: disable=no-member
|
||||
self.request.backend = social_utils.load_backend(self.request.social_strategy, self.BACKEND, redirect_uri)
|
||||
|
||||
def _assert_error(self, data, expected_error, expected_error_description):
|
||||
form = AccessTokenExchangeForm(request=self.request, data=data)
|
||||
|
||||
@@ -20,6 +20,7 @@ from external_auth.views import (
|
||||
shib_login, course_specific_login, course_specific_register, _flatten_to_ascii
|
||||
)
|
||||
from mock import patch
|
||||
from urllib import urlencode
|
||||
|
||||
from student.views import create_account, change_enrollment
|
||||
from student.models import UserProfile, CourseEnrollment
|
||||
@@ -169,7 +170,7 @@ class ShibSPTest(ModuleStoreTestCase):
|
||||
if idp == "https://idp.stanford.edu/" and remote_user == 'withmap@stanford.edu':
|
||||
self.assertIsInstance(response, HttpResponseRedirect)
|
||||
self.assertEqual(request.user, user_w_map)
|
||||
self.assertEqual(response['Location'], '/')
|
||||
self.assertEqual(response['Location'], '/dashboard')
|
||||
# verify logging:
|
||||
self.assertEquals(len(audit_log_calls), 2)
|
||||
self._assert_shib_login_is_logged(audit_log_calls[0], remote_user)
|
||||
@@ -193,7 +194,7 @@ class ShibSPTest(ModuleStoreTestCase):
|
||||
self.assertIsNotNone(ExternalAuthMap.objects.get(user=user_wo_map))
|
||||
self.assertIsInstance(response, HttpResponseRedirect)
|
||||
self.assertEqual(request.user, user_wo_map)
|
||||
self.assertEqual(response['Location'], '/')
|
||||
self.assertEqual(response['Location'], '/dashboard')
|
||||
# verify logging:
|
||||
self.assertEquals(len(audit_log_calls), 2)
|
||||
self._assert_shib_login_is_logged(audit_log_calls[0], remote_user)
|
||||
@@ -242,7 +243,7 @@ class ShibSPTest(ModuleStoreTestCase):
|
||||
self.assertTrue(inactive_user.is_active)
|
||||
self.assertIsInstance(response, HttpResponseRedirect)
|
||||
self.assertEqual(request.user, inactive_user)
|
||||
self.assertEqual(response['Location'], '/')
|
||||
self.assertEqual(response['Location'], '/dashboard')
|
||||
# verify logging:
|
||||
self.assertEquals(len(audit_log_calls), 3)
|
||||
self._assert_shib_login_is_logged(audit_log_calls[0], log_user_string)
|
||||
@@ -549,29 +550,20 @@ class ShibSPTest(ModuleStoreTestCase):
|
||||
# no enrollment before trying
|
||||
self.assertFalse(CourseEnrollment.is_enrolled(student, course.id))
|
||||
self.client.logout()
|
||||
params = [
|
||||
('course_id', course.id.to_deprecated_string()),
|
||||
('enrollment_action', 'enroll'),
|
||||
('next', '/testredirect')
|
||||
]
|
||||
request_kwargs = {'path': '/shib-login/',
|
||||
'data': {'enrollment_action': 'enroll', 'course_id': course.id.to_deprecated_string(), 'next': '/testredirect'},
|
||||
'data': dict(params),
|
||||
'follow': False,
|
||||
'REMOTE_USER': 'testuser@stanford.edu',
|
||||
'Shib-Identity-Provider': 'https://idp.stanford.edu/'}
|
||||
response = self.client.get(**request_kwargs)
|
||||
# successful login is a redirect to "/"
|
||||
# successful login is a redirect to the URL that handles auto-enrollment
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response['location'], 'http://testserver/testredirect')
|
||||
# now there is enrollment
|
||||
self.assertTrue(CourseEnrollment.is_enrolled(student, course.id))
|
||||
|
||||
# Clean up and try again with POST (doesn't happen with real production shib, doing this for test coverage)
|
||||
self.client.logout()
|
||||
CourseEnrollment.unenroll(student, course.id)
|
||||
self.assertFalse(CourseEnrollment.is_enrolled(student, course.id))
|
||||
|
||||
response = self.client.post(**request_kwargs)
|
||||
# successful login is a redirect to "/"
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response['location'], 'http://testserver/testredirect')
|
||||
# now there is enrollment
|
||||
self.assertTrue(CourseEnrollment.is_enrolled(student, course.id))
|
||||
self.assertEqual(response['location'], 'http://testserver/account/finish_auth?{}'.format(urlencode(params)))
|
||||
|
||||
|
||||
class ShibUtilFnTest(TestCase):
|
||||
|
||||
@@ -22,6 +22,7 @@ from django.core.exceptions import ValidationError
|
||||
if settings.FEATURES.get('AUTH_USE_CAS'):
|
||||
from django_cas.views import login as django_cas_login
|
||||
|
||||
from student.helpers import get_next_url_for_login_page
|
||||
from student.models import UserProfile
|
||||
|
||||
from django.http import HttpResponse, HttpResponseRedirect, HttpRequest, HttpResponseForbidden
|
||||
@@ -118,7 +119,8 @@ def openid_login_complete(request,
|
||||
external_domain,
|
||||
details,
|
||||
details.get('email', ''),
|
||||
fullname
|
||||
fullname,
|
||||
retfun=functools.partial(redirect, get_next_url_for_login_page(request)),
|
||||
)
|
||||
|
||||
return render_failure(request, 'Openid failure')
|
||||
@@ -236,14 +238,6 @@ def _external_login_or_signup(request,
|
||||
login(request, user)
|
||||
request.session.set_expiry(0)
|
||||
|
||||
# Now to try enrollment
|
||||
# Need to special case Shibboleth here because it logs in via a GET.
|
||||
# testing request.method for extra paranoia
|
||||
if uses_shibboleth and request.method == 'GET':
|
||||
enroll_request = _make_shib_enrollment_request(request)
|
||||
student.views.try_change_enrollment(enroll_request)
|
||||
else:
|
||||
student.views.try_change_enrollment(request)
|
||||
if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
|
||||
AUDIT_LOG.info(u"Login success - user.id: {0}".format(user.id))
|
||||
else:
|
||||
@@ -449,9 +443,7 @@ def ssl_login(request):
|
||||
|
||||
(_user, email, fullname) = _ssl_dn_extract_info(cert)
|
||||
|
||||
redirect_to = request.GET.get('next')
|
||||
if not redirect_to:
|
||||
redirect_to = '/'
|
||||
redirect_to = get_next_url_for_login_page(request)
|
||||
retfun = functools.partial(redirect, redirect_to)
|
||||
return _external_login_or_signup(
|
||||
request,
|
||||
@@ -528,10 +520,8 @@ def shib_login(request):
|
||||
|
||||
fullname = shib['displayName'] if shib['displayName'] else u'%s %s' % (shib['givenName'], shib['sn'])
|
||||
|
||||
redirect_to = request.REQUEST.get('next')
|
||||
retfun = None
|
||||
if redirect_to:
|
||||
retfun = functools.partial(_safe_postlogin_redirect, redirect_to, request.get_host())
|
||||
redirect_to = get_next_url_for_login_page(request)
|
||||
retfun = functools.partial(_safe_postlogin_redirect, redirect_to, request.get_host())
|
||||
|
||||
return _external_login_or_signup(
|
||||
request,
|
||||
@@ -558,31 +548,6 @@ def _safe_postlogin_redirect(redirect_to, safehost, default_redirect='/'):
|
||||
return redirect(default_redirect)
|
||||
|
||||
|
||||
def _make_shib_enrollment_request(request):
|
||||
"""
|
||||
Need this hack function because shibboleth logins don't happen over POST
|
||||
but change_enrollment expects its request to be a POST, with
|
||||
enrollment_action and course_id POST parameters.
|
||||
"""
|
||||
enroll_request = HttpRequest()
|
||||
enroll_request.user = request.user
|
||||
enroll_request.session = request.session
|
||||
enroll_request.method = "POST"
|
||||
|
||||
# copy() also makes GET and POST mutable
|
||||
# See https://docs.djangoproject.com/en/dev/ref/request-response/#django.http.QueryDict.update
|
||||
enroll_request.GET = request.GET.copy()
|
||||
enroll_request.POST = request.POST.copy()
|
||||
|
||||
# also have to copy these GET parameters over to POST
|
||||
if "enrollment_action" not in enroll_request.POST and "enrollment_action" in enroll_request.GET:
|
||||
enroll_request.POST.setdefault('enrollment_action', enroll_request.GET.get('enrollment_action'))
|
||||
if "course_id" not in enroll_request.POST and "course_id" in enroll_request.GET:
|
||||
enroll_request.POST.setdefault('course_id', enroll_request.GET.get('course_id'))
|
||||
|
||||
return enroll_request
|
||||
|
||||
|
||||
def course_specific_login(request, course_id):
|
||||
"""
|
||||
Dispatcher function for selecting the specific login method
|
||||
|
||||
@@ -4,9 +4,11 @@ from datetime import datetime
|
||||
from pytz import UTC
|
||||
from django.utils.http import cookie_date
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse, NoReverseMatch
|
||||
import third_party_auth
|
||||
import urllib
|
||||
from verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=F0401
|
||||
from course_modes.models import CourseMode
|
||||
from student_account.helpers import auth_pipeline_urls # pylint: disable=unused-import,import-error
|
||||
|
||||
|
||||
def set_logged_in_cookie(request, response):
|
||||
@@ -199,3 +201,70 @@ def check_verify_status_by_course(user, course_enrollment_pairs, all_course_mode
|
||||
status_by_course[key]['verification_good_until'] = recent_verification_datetime.strftime("%m/%d/%Y")
|
||||
|
||||
return status_by_course
|
||||
|
||||
|
||||
def auth_pipeline_urls(auth_entry, redirect_url=None):
|
||||
"""Retrieve URLs for each enabled third-party auth provider.
|
||||
|
||||
These URLs are used on the "sign up" and "sign in" buttons
|
||||
on the login/registration forms to allow users to begin
|
||||
authentication with a third-party provider.
|
||||
|
||||
Optionally, we can redirect the user to an arbitrary
|
||||
url after auth completes successfully. We use this
|
||||
to redirect the user to a page that required login,
|
||||
or to send users to the payment flow when enrolling
|
||||
in a course.
|
||||
|
||||
Args:
|
||||
auth_entry (string): Either `pipeline.AUTH_ENTRY_LOGIN` or `pipeline.AUTH_ENTRY_REGISTER`
|
||||
|
||||
Keyword Args:
|
||||
redirect_url (unicode): If provided, send users to this URL
|
||||
after they successfully authenticate.
|
||||
|
||||
Returns:
|
||||
dict mapping provider IDs to URLs
|
||||
|
||||
"""
|
||||
if not third_party_auth.is_enabled():
|
||||
return {}
|
||||
|
||||
return {
|
||||
provider.NAME: third_party_auth.pipeline.get_login_url(provider.NAME, auth_entry, redirect_url=redirect_url)
|
||||
for provider in third_party_auth.provider.Registry.enabled()
|
||||
}
|
||||
|
||||
|
||||
# Query string parameters that can be passed to the "finish_auth" view to manage
|
||||
# things like auto-enrollment.
|
||||
POST_AUTH_PARAMS = ('course_id', 'enrollment_action', 'course_mode', 'email_opt_in')
|
||||
|
||||
|
||||
def get_next_url_for_login_page(request):
|
||||
"""
|
||||
Determine the URL to redirect to following login/registration/third_party_auth
|
||||
|
||||
The user is currently on a login or reigration page.
|
||||
If 'course_id' is set, or other POST_AUTH_PARAMS, we will need to send the user to the
|
||||
/account/finish_auth/ view following login, which will take care of auto-enrollment in
|
||||
the specified course.
|
||||
|
||||
Otherwise, we go to the ?next= query param or to the dashboard if nothing else is
|
||||
specified.
|
||||
"""
|
||||
redirect_to = request.GET.get('next', None)
|
||||
if not redirect_to:
|
||||
try:
|
||||
redirect_to = reverse('dashboard')
|
||||
except NoReverseMatch:
|
||||
redirect_to = reverse('home')
|
||||
if any(param in request.GET for param in POST_AUTH_PARAMS):
|
||||
# Before we redirect to next/dashboard, we need to handle auto-enrollment:
|
||||
params = [(param, request.GET[param]) for param in POST_AUTH_PARAMS if param in request.GET]
|
||||
params.append(('next', redirect_to)) # After auto-enrollment, user will be sent to payment page or to this URL
|
||||
redirect_to = '{}?{}'.format(reverse('finish_auth'), urllib.urlencode(params))
|
||||
# Note: if we are resuming a third party auth pipeline, then the next URL will already
|
||||
# be saved in the session as part of the pipeline state. That URL will take priority
|
||||
# over this one.
|
||||
return redirect_to
|
||||
|
||||
@@ -278,24 +278,6 @@ class LoginTest(TestCase):
|
||||
self.assertIsNone(response_content["redirect_url"])
|
||||
self._assert_response(response, success=True)
|
||||
|
||||
def test_change_enrollment_200_redirect(self):
|
||||
"""
|
||||
Tests that "redirect_url" is the content of the HttpResponse returned
|
||||
by change_enrollment, if there is content
|
||||
"""
|
||||
# add this post param to trigger a call to change_enrollment
|
||||
extra_post_params = {"enrollment_action": "enroll"}
|
||||
with patch('student.views.change_enrollment') as mock_change_enrollment:
|
||||
mock_change_enrollment.return_value = HttpResponse("in/nature/there/is/nothing/melancholy")
|
||||
response, _ = self._login_response(
|
||||
'test@edx.org',
|
||||
'test_password',
|
||||
extra_post_params=extra_post_params,
|
||||
)
|
||||
response_content = json.loads(response.content)
|
||||
self.assertEqual(response_content["redirect_url"], "in/nature/there/is/nothing/melancholy")
|
||||
self._assert_response(response, success=True)
|
||||
|
||||
def _login_response(self, email, password, patched_audit_log='student.views.AUDIT_LOG', extra_post_params=None):
|
||||
''' Post the login info '''
|
||||
post_params = {'email': email, 'password': password}
|
||||
|
||||
@@ -21,13 +21,11 @@ THIRD_PARTY_AUTH_BACKENDS = ["google-oauth2", "facebook"]
|
||||
THIRD_PARTY_AUTH_PROVIDERS = ["Google", "Facebook"]
|
||||
|
||||
|
||||
def _third_party_login_url(backend_name, auth_entry, course_id=None, redirect_url=None):
|
||||
def _third_party_login_url(backend_name, auth_entry, redirect_url=None):
|
||||
"""Construct the login URL to start third party authentication. """
|
||||
params = [("auth_entry", auth_entry)]
|
||||
if redirect_url:
|
||||
params.append(("next", redirect_url))
|
||||
if course_id:
|
||||
params.append(("enroll_course_id", course_id))
|
||||
|
||||
return u"{url}?{params}".format(
|
||||
url=reverse("social:begin", kwargs={"backend": backend_name}),
|
||||
@@ -35,6 +33,11 @@ def _third_party_login_url(backend_name, auth_entry, course_id=None, redirect_ur
|
||||
)
|
||||
|
||||
|
||||
def _finish_auth_url(params):
|
||||
""" Construct the URL that follows login/registration if we are doing auto-enrollment """
|
||||
return u"{}?{}".format(reverse('finish_auth'), urllib.urlencode(params))
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
class LoginFormTest(UrlResetMixin, ModuleStoreTestCase):
|
||||
@@ -46,7 +49,6 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase):
|
||||
self.url = reverse("signin_user")
|
||||
self.course = CourseFactory.create()
|
||||
self.course_id = unicode(self.course.id)
|
||||
self.course_modes_url = reverse("course_modes_choose", kwargs={"course_id": self.course_id})
|
||||
self.courseware_url = reverse("courseware", args=[self.course_id])
|
||||
|
||||
@patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False})
|
||||
@@ -65,7 +67,8 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase):
|
||||
def test_third_party_auth_with_course_id(self, backend_name):
|
||||
# Provide a course ID to the login page, simulating what happens
|
||||
# when a user tries to enroll in a course without being logged in
|
||||
response = self.client.get(self.url, {"course_id": self.course_id})
|
||||
params = [('course_id', self.course_id)]
|
||||
response = self.client.get(self.url, params)
|
||||
|
||||
# Expect that the course ID is added to the third party auth entry
|
||||
# point, so that the pipeline will enroll the student and
|
||||
@@ -73,35 +76,12 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase):
|
||||
expected_url = _third_party_login_url(
|
||||
backend_name,
|
||||
"login",
|
||||
course_id=self.course_id,
|
||||
redirect_url=self.course_modes_url
|
||||
redirect_url=_finish_auth_url(params),
|
||||
)
|
||||
self.assertContains(response, expected_url)
|
||||
|
||||
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
|
||||
def test_third_party_auth_with_white_label_course(self, backend_name):
|
||||
# Set the course mode to honor with a min price,
|
||||
# indicating that the course is behind a paywall.
|
||||
CourseModeFactory.create(
|
||||
course_id=self.course.id,
|
||||
mode_slug="honor",
|
||||
mode_display_name="Honor",
|
||||
min_price=100
|
||||
)
|
||||
|
||||
# Expect that we're redirected to the shopping cart
|
||||
# instead of to the track selection page.
|
||||
response = self.client.get(self.url, {"course_id": self.course_id})
|
||||
expected_url = _third_party_login_url(
|
||||
backend_name,
|
||||
"login",
|
||||
course_id=self.course_id,
|
||||
redirect_url=reverse("shoppingcart.views.show_cart")
|
||||
)
|
||||
self.assertContains(response, expected_url)
|
||||
|
||||
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
|
||||
def test_third_party_auth_with_redirect_url(self, backend_name):
|
||||
def test_courseware_redirect(self, backend_name):
|
||||
# Try to access courseware while logged out, expecting to be
|
||||
# redirected to the login page.
|
||||
response = self.client.get(self.courseware_url, follow=True)
|
||||
@@ -123,28 +103,47 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase):
|
||||
)
|
||||
self.assertContains(response, expected_url)
|
||||
|
||||
@ddt.data(None, "true", "false")
|
||||
def test_email_opt_in(self, opt_in_value):
|
||||
params = {
|
||||
'course_id': self.course_id,
|
||||
'enrollment_action': 'enroll'
|
||||
}
|
||||
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
|
||||
def test_third_party_auth_with_params(self, backend_name):
|
||||
params = [
|
||||
('course_id', self.course_id),
|
||||
('enrollment_action', 'enroll'),
|
||||
('course_mode', 'honor'),
|
||||
('email_opt_in', 'true'),
|
||||
('next', '/custom/final/destination'),
|
||||
]
|
||||
response = self.client.get(self.url, params)
|
||||
expected_url = _third_party_login_url(
|
||||
backend_name,
|
||||
"login",
|
||||
redirect_url=_finish_auth_url(params),
|
||||
)
|
||||
self.assertContains(response, expected_url)
|
||||
|
||||
if opt_in_value is not None:
|
||||
params['email_opt_in'] = opt_in_value
|
||||
@ddt.data(None, "true", "false")
|
||||
def test_params(self, opt_in_value):
|
||||
params = [
|
||||
('course_id', self.course_id),
|
||||
('enrollment_action', 'enroll'),
|
||||
('course_mode', 'honor'),
|
||||
('email_opt_in', opt_in_value),
|
||||
('next', '/custom/final/destination'),
|
||||
]
|
||||
|
||||
# Get the login page
|
||||
response = self.client.get(self.url, params)
|
||||
|
||||
# Verify that the hidden parameter is set correctly
|
||||
hidden_param = '<input type="hidden" name="email_opt_in" value="{val}"'.format(
|
||||
val=opt_in_value
|
||||
)
|
||||
# Verify that the parameters are sent on to the next page correctly
|
||||
post_login_handler = _finish_auth_url(params)
|
||||
js_success_var = 'var nextUrl = "{}";'.format(post_login_handler)
|
||||
self.assertContains(response, js_success_var)
|
||||
|
||||
if opt_in_value is not None:
|
||||
self.assertContains(response, hidden_param)
|
||||
else:
|
||||
self.assertNotContains(response, hidden_param)
|
||||
# Verify that the login link preserves the querystring params
|
||||
login_link = u"{url}?{params}".format(
|
||||
url=reverse('signin_user'),
|
||||
params=urllib.urlencode([('next', post_login_handler)])
|
||||
)
|
||||
self.assertContains(response, login_link)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@@ -158,7 +157,6 @@ class RegisterFormTest(UrlResetMixin, ModuleStoreTestCase):
|
||||
self.url = reverse("register_user")
|
||||
self.course = CourseFactory.create()
|
||||
self.course_id = unicode(self.course.id)
|
||||
self.course_modes_url = reverse("course_modes_choose", kwargs={"course_id": self.course_id})
|
||||
|
||||
@patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False})
|
||||
@ddt.data(*THIRD_PARTY_AUTH_PROVIDERS)
|
||||
@@ -173,63 +171,43 @@ class RegisterFormTest(UrlResetMixin, ModuleStoreTestCase):
|
||||
self.assertContains(response, expected_url)
|
||||
|
||||
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
|
||||
def test_register_third_party_auth_with_course_id(self, backend_name):
|
||||
response = self.client.get(self.url, {"course_id": self.course_id})
|
||||
def test_register_third_party_auth_with_params(self, backend_name):
|
||||
params = [
|
||||
('course_id', self.course_id),
|
||||
('enrollment_action', 'enroll'),
|
||||
('course_mode', 'honor'),
|
||||
('email_opt_in', 'true'),
|
||||
('next', '/custom/final/destination'),
|
||||
]
|
||||
response = self.client.get(self.url, params)
|
||||
expected_url = _third_party_login_url(
|
||||
backend_name,
|
||||
"register",
|
||||
course_id=self.course_id,
|
||||
redirect_url=self.course_modes_url
|
||||
)
|
||||
self.assertContains(response, expected_url)
|
||||
|
||||
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
|
||||
def test_third_party_auth_with_white_label_course(self, backend_name):
|
||||
# Set the course mode to honor with a min price,
|
||||
# indicating that the course is behind a paywall.
|
||||
CourseModeFactory.create(
|
||||
course_id=self.course.id,
|
||||
mode_slug="honor",
|
||||
mode_display_name="Honor",
|
||||
min_price=100
|
||||
)
|
||||
|
||||
# Expect that we're redirected to the shopping cart
|
||||
# instead of to the track selection page.
|
||||
response = self.client.get(self.url, {"course_id": self.course_id})
|
||||
expected_url = _third_party_login_url(
|
||||
backend_name,
|
||||
"register",
|
||||
course_id=self.course_id,
|
||||
redirect_url=reverse("shoppingcart.views.show_cart")
|
||||
redirect_url=_finish_auth_url(params),
|
||||
)
|
||||
self.assertContains(response, expected_url)
|
||||
|
||||
@ddt.data(None, "true", "false")
|
||||
def test_email_opt_in(self, opt_in_value):
|
||||
params = OrderedDict()
|
||||
params['course_id'] = self.course_id
|
||||
params['enrollment_action'] = 'enroll'
|
||||
|
||||
if opt_in_value is not None:
|
||||
params['email_opt_in'] = opt_in_value
|
||||
def test_params(self, opt_in_value):
|
||||
params = [
|
||||
('course_id', self.course_id),
|
||||
('enrollment_action', 'enroll'),
|
||||
('course_mode', 'honor'),
|
||||
('email_opt_in', opt_in_value),
|
||||
('next', '/custom/final/destination'),
|
||||
]
|
||||
|
||||
# Get the login page
|
||||
response = self.client.get(self.url, params)
|
||||
|
||||
# Verify that the hidden parameter is set correctly
|
||||
hidden_param = '<input type="hidden" name="email_opt_in" value="{val}"'.format(
|
||||
val=opt_in_value
|
||||
)
|
||||
|
||||
if opt_in_value is not None:
|
||||
self.assertContains(response, hidden_param)
|
||||
else:
|
||||
self.assertNotContains(response, hidden_param)
|
||||
# Verify that the parameters are sent on to the next page correctly
|
||||
post_login_handler = _finish_auth_url(params)
|
||||
js_success_var = 'var nextUrl = "{}";'.format(post_login_handler)
|
||||
self.assertContains(response, js_success_var)
|
||||
|
||||
# Verify that the login link preserves the querystring params
|
||||
login_link = u"{url}?{params}".format(
|
||||
url=reverse('signin_user'),
|
||||
params=urllib.urlencode(params)
|
||||
params=urllib.urlencode([('next', post_login_handler)])
|
||||
)
|
||||
self.assertContains(response, login_link)
|
||||
|
||||
@@ -819,38 +819,6 @@ class ChangeEnrollmentViewTest(ModuleStoreTestCase):
|
||||
self.assertEqual(enrollment_mode, u'honor')
|
||||
|
||||
|
||||
class PaidRegistrationTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for paid registration functionality (not verified student), involves shoppingcart
|
||||
"""
|
||||
def setUp(self):
|
||||
super(PaidRegistrationTest, self).setUp()
|
||||
# Create course
|
||||
self.course = CourseFactory.create()
|
||||
self.req_factory = RequestFactory()
|
||||
self.user = User.objects.create(username="jack", email="jack@fake.edx.org")
|
||||
|
||||
@unittest.skipUnless(settings.FEATURES.get('ENABLE_SHOPPING_CART'), "Shopping Cart not enabled in settings")
|
||||
def test_change_enrollment_add_to_cart(self):
|
||||
request = self.req_factory.post(
|
||||
reverse('change_enrollment'), {
|
||||
'course_id': self.course.id.to_deprecated_string(),
|
||||
'enrollment_action': 'add_to_cart'
|
||||
}
|
||||
)
|
||||
|
||||
# Add a session to the request
|
||||
SessionMiddleware().process_request(request)
|
||||
request.session.save()
|
||||
|
||||
request.user = self.user
|
||||
response = change_enrollment(request)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.content, reverse('shoppingcart.views.show_cart'))
|
||||
self.assertTrue(shoppingcart.models.PaidCourseRegistration.contained_in_order(
|
||||
shoppingcart.models.Order.get_cart_for_user(self.user), self.course.id))
|
||||
|
||||
|
||||
class AnonymousLookupTable(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for anonymous_id_functions
|
||||
|
||||
@@ -106,8 +106,8 @@ from util.password_policy_validators import (
|
||||
import third_party_auth
|
||||
from third_party_auth import pipeline, provider
|
||||
from student.helpers import (
|
||||
auth_pipeline_urls, set_logged_in_cookie,
|
||||
check_verify_status_by_course
|
||||
set_logged_in_cookie, check_verify_status_by_course,
|
||||
auth_pipeline_urls, get_next_url_for_login_page
|
||||
)
|
||||
from student.models import anonymous_id_for_user
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
@@ -356,24 +356,30 @@ def signin_user(request):
|
||||
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(reverse('dashboard'))
|
||||
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 = _(msg) # pylint: disable=translation-of-non-string
|
||||
break
|
||||
|
||||
course_id = request.GET.get('course_id')
|
||||
email_opt_in = request.GET.get('email_opt_in')
|
||||
context = {
|
||||
'course_id': course_id,
|
||||
'email_opt_in': email_opt_in,
|
||||
'enrollment_action': request.GET.get('enrollment_action'),
|
||||
'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, course_id=course_id, email_opt_in=email_opt_in),
|
||||
'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to),
|
||||
'platform_name': microsite.get_value(
|
||||
'platform_name',
|
||||
settings.PLATFORM_NAME
|
||||
),
|
||||
'third_party_auth_error': third_party_auth_error
|
||||
}
|
||||
|
||||
return render_to_response('login.html', context)
|
||||
@@ -382,24 +388,21 @@ def signin_user(request):
|
||||
@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(reverse('dashboard'))
|
||||
return redirect(redirect_to)
|
||||
|
||||
external_auth_response = external_auth_register(request)
|
||||
if external_auth_response is not None:
|
||||
return external_auth_response
|
||||
|
||||
course_id = request.GET.get('course_id')
|
||||
email_opt_in = request.GET.get('email_opt_in')
|
||||
|
||||
context = {
|
||||
'course_id': course_id,
|
||||
'email_opt_in': email_opt_in,
|
||||
'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header
|
||||
'email': '',
|
||||
'enrollment_action': request.GET.get('enrollment_action'),
|
||||
'name': '',
|
||||
'running_pipeline': None,
|
||||
'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, course_id=course_id, email_opt_in=email_opt_in),
|
||||
'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, redirect_url=redirect_to),
|
||||
'platform_name': microsite.get_value(
|
||||
'platform_name',
|
||||
settings.PLATFORM_NAME
|
||||
@@ -729,33 +732,6 @@ def _allow_donation(course_modes, course_id, enrollment):
|
||||
return donations_enabled and enrollment.mode in course_modes[course_id] and course_modes[course_id][enrollment.mode].min_price == 0
|
||||
|
||||
|
||||
def try_change_enrollment(request):
|
||||
"""
|
||||
This method calls change_enrollment if the necessary POST
|
||||
parameters are present, but does not return anything in most cases. It
|
||||
simply logs the result or exception. This is usually
|
||||
called after a registration or login, as secondary action.
|
||||
It should not interrupt a successful registration or login.
|
||||
"""
|
||||
if 'enrollment_action' in request.POST:
|
||||
try:
|
||||
enrollment_response = change_enrollment(request)
|
||||
# There isn't really a way to display the results to the user, so we just log it
|
||||
# We expect the enrollment to be a success, and will show up on the dashboard anyway
|
||||
log.info(
|
||||
u"Attempted to automatically enroll after login. Response code: %s; response body: %s",
|
||||
enrollment_response.status_code,
|
||||
enrollment_response.content
|
||||
)
|
||||
# Hack: since change_enrollment delivers its redirect_url in the content
|
||||
# of its response, we check here that only the 200 codes with content
|
||||
# will return redirect_urls.
|
||||
if enrollment_response.status_code == 200 and enrollment_response.content != '':
|
||||
return enrollment_response.content
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
log.exception(u"Exception automatically enrolling after login: %s", exc)
|
||||
|
||||
|
||||
def _update_email_opt_in(request, org):
|
||||
"""Helper function used to hit the profile API if email opt-in is enabled."""
|
||||
|
||||
@@ -780,9 +756,8 @@ def change_enrollment(request, check_access=True):
|
||||
course, a 400 error will be returned. If the user is not logged in, 403
|
||||
will be returned; it is important that only this case return 403 so the
|
||||
front end can redirect the user to a registration or login page when this
|
||||
happens. This function should only be called from an AJAX request or
|
||||
as a post-login/registration helper, so the error messages in the responses
|
||||
should never actually be user-visible.
|
||||
happens. This function should only be called from an AJAX request, so
|
||||
the error messages in the responses should never actually be user-visible.
|
||||
|
||||
Args:
|
||||
request (`Request`): The Django request object
|
||||
@@ -874,20 +849,6 @@ def change_enrollment(request, check_access=True):
|
||||
|
||||
# Otherwise, there is only one mode available (the default)
|
||||
return HttpResponse()
|
||||
|
||||
elif action == "add_to_cart":
|
||||
# Pass the request handling to shoppingcart.views
|
||||
# The view in shoppingcart.views performs error handling and logs different errors. But this elif clause
|
||||
# is only used in the "auto-add after user reg/login" case, i.e. it's always wrapped in try_change_enrollment.
|
||||
# This means there's no good way to display error messages to the user. So we log the errors and send
|
||||
# the user to the shopping cart page always, where they can reasonably discern the status of their cart,
|
||||
# whether things got added, etc
|
||||
|
||||
shoppingcart.views.add_course_to_cart(request, course_id.to_deprecated_string())
|
||||
return HttpResponse(
|
||||
reverse("shoppingcart.views.show_cart")
|
||||
)
|
||||
|
||||
elif action == "unenroll":
|
||||
if not CourseEnrollment.is_enrolled(user, course_id):
|
||||
return HttpResponseBadRequest(_("You are not enrolled in this course"))
|
||||
@@ -905,8 +866,9 @@ def accounts_login(request):
|
||||
if external_auth_response is not None:
|
||||
return external_auth_response
|
||||
|
||||
redirect_to = request.GET.get('next')
|
||||
redirect_to = get_next_url_for_login_page(request)
|
||||
context = {
|
||||
'login_redirect_url': redirect_to,
|
||||
'pipeline_running': 'false',
|
||||
'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to),
|
||||
'platform_name': settings.PLATFORM_NAME,
|
||||
@@ -1091,8 +1053,7 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
|
||||
log.exception(exc)
|
||||
raise
|
||||
|
||||
redirect_url = try_change_enrollment(request)
|
||||
|
||||
redirect_url = None # The AJAX method calling should know the default destination upon success
|
||||
if third_party_auth_successful:
|
||||
redirect_url = pipeline.get_complete_url(backend_name)
|
||||
|
||||
@@ -1129,7 +1090,7 @@ def login_oauth_token(request, backend):
|
||||
"""
|
||||
warnings.warn("Please use AccessTokenExchangeView instead.", DeprecationWarning)
|
||||
|
||||
backend = request.social_strategy.backend
|
||||
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
|
||||
@@ -1137,7 +1098,7 @@ def login_oauth_token(request, backend):
|
||||
user = None
|
||||
try:
|
||||
user = backend.do_auth(request.POST["access_token"])
|
||||
except HTTPError:
|
||||
except (HTTPError, AuthException):
|
||||
pass
|
||||
# do_auth can return a non-User object if it fails
|
||||
if user and isinstance(user, User):
|
||||
@@ -1445,9 +1406,13 @@ def create_account_with_params(request, params):
|
||||
# first, create the account
|
||||
(user, profile, registration) = _do_create_account(form)
|
||||
|
||||
# next, link the account with social auth, if provided
|
||||
# next, link the account with social auth, if provided via the API.
|
||||
# (If the user is using the normal register page, the social auth pipeline does the linking, not this code)
|
||||
if should_link_with_social_auth:
|
||||
request.social_strategy = social_utils.load_strategy(backend=params['provider'], request=request)
|
||||
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({
|
||||
@@ -1461,7 +1426,7 @@ def create_account_with_params(request, params):
|
||||
pipeline_user = None
|
||||
error_message = ""
|
||||
try:
|
||||
pipeline_user = request.social_strategy.backend.do_auth(social_access_token, user=user)
|
||||
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):
|
||||
@@ -1635,7 +1600,7 @@ def create_account(request, post_override=None):
|
||||
status=400
|
||||
)
|
||||
|
||||
redirect_url = try_change_enrollment(request)
|
||||
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):
|
||||
|
||||
58
common/djangoapps/third_party_auth/dummy.py
Normal file
58
common/djangoapps/third_party_auth/dummy.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
DummyProvider: A fake Third Party Auth provider for testing & development purposes.
|
||||
"""
|
||||
from social.backends.base import BaseAuth
|
||||
from social.exceptions import AuthFailed
|
||||
|
||||
from .provider import BaseProvider
|
||||
|
||||
|
||||
class DummyBackend(BaseAuth): # pylint: disable=abstract-method
|
||||
"""
|
||||
python-social-auth backend that doesn't actually go to any third party site
|
||||
"""
|
||||
name = "dummy"
|
||||
SUCCEED = True # You can patch this during tests in order to control whether or not login works
|
||||
|
||||
def auth_url(self):
|
||||
""" Get the URL to which we must redirect in order to authenticate the user """
|
||||
return self.redirect_uri
|
||||
|
||||
def get_user_details(self, response):
|
||||
""" Get user details like full name, email, etc. from the third party """
|
||||
return {
|
||||
'fullname': "William Adama",
|
||||
'first_name': "Bill",
|
||||
'last_name': "Adama",
|
||||
'username': "Galactica1",
|
||||
'email': "adama@fleet.colonies.gov",
|
||||
}
|
||||
|
||||
def get_user_id(self, details, response):
|
||||
""" Get the permanent ID for this user from the third party. """
|
||||
return '1234'
|
||||
|
||||
def auth_complete(self, *args, **kwargs):
|
||||
"""
|
||||
The user has been redirected back from the third party and we should now log them in, if
|
||||
everything checks out.
|
||||
"""
|
||||
if not DummyBackend.SUCCEED:
|
||||
raise AuthFailed(self, 'Third Party login failed.')
|
||||
|
||||
response = {
|
||||
'dummy': True,
|
||||
}
|
||||
|
||||
kwargs.update({'response': response, 'backend': self})
|
||||
|
||||
return self.strategy.authenticate(*args, **kwargs)
|
||||
|
||||
|
||||
class DummyProvider(BaseProvider):
|
||||
""" Dummy Provider for testing and development """
|
||||
|
||||
BACKEND_CLASS = DummyBackend
|
||||
ICON_CLASS = 'fa-cube'
|
||||
NAME = 'Dummy'
|
||||
SETTINGS = {}
|
||||
@@ -69,22 +69,13 @@ from django.contrib.auth.models import User
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import ugettext as _
|
||||
from social.apps.django_app.default import models
|
||||
from social.exceptions import AuthException
|
||||
from social.pipeline import partial
|
||||
from social.pipeline.social_auth import associate_by_email
|
||||
|
||||
import student
|
||||
from embargo import api as embargo_api
|
||||
from shoppingcart.models import Order, PaidCourseRegistration # pylint: disable=import-error
|
||||
from shoppingcart.exceptions import ( # pylint: disable=import-error
|
||||
CourseDoesNotExistException,
|
||||
ItemAlreadyInCartException,
|
||||
AlreadyEnrolledInCourseException
|
||||
)
|
||||
from student.models import CourseEnrollment, CourseEnrollmentException
|
||||
from course_modes.models import CourseMode
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
@@ -103,14 +94,8 @@ from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
|
||||
# `AUTH_REDIRECT_KEY` provides an optional URL to redirect
|
||||
# to upon successful authentication
|
||||
# (if not provided, defaults to `_SOCIAL_AUTH_LOGIN_REDIRECT_URL`)
|
||||
#
|
||||
# `AUTH_ENROLL_COURSE_ID_KEY` provides the course ID that a student
|
||||
# is trying to enroll in, used to generate analytics events
|
||||
# and auto-enroll students.
|
||||
AUTH_ENTRY_KEY = 'auth_entry'
|
||||
AUTH_REDIRECT_KEY = 'next'
|
||||
AUTH_ENROLL_COURSE_ID_KEY = 'enroll_course_id'
|
||||
AUTH_EMAIL_OPT_IN_KEY = 'email_opt_in'
|
||||
|
||||
|
||||
# The following are various possible values for the AUTH_ENTRY_KEY.
|
||||
@@ -192,6 +177,19 @@ class AuthEntryError(AuthException):
|
||||
"""
|
||||
|
||||
|
||||
class NotActivatedException(AuthException):
|
||||
""" Raised when a user tries to login to an unverified account """
|
||||
def __init__(self, backend, email):
|
||||
self.email = email
|
||||
super(NotActivatedException, self).__init__(backend, email)
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
_('This account has not yet been activated. An activation email has been re-sent to {email_address}.')
|
||||
.format(email_address=self.email)
|
||||
)
|
||||
|
||||
|
||||
class ProviderUserState(object):
|
||||
"""Object representing the provider state (attached or not) for a user.
|
||||
|
||||
@@ -260,7 +258,7 @@ def _get_enabled_provider_by_name(provider_name):
|
||||
return enabled_provider
|
||||
|
||||
|
||||
def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None, enroll_course_id=None, email_opt_in=None):
|
||||
def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None):
|
||||
"""Creates a URL to hook into social auth endpoints."""
|
||||
kwargs = {'backend': backend_name}
|
||||
url = reverse(view_name, kwargs=kwargs)
|
||||
@@ -272,12 +270,6 @@ def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None, enroll
|
||||
if redirect_url:
|
||||
query_params[AUTH_REDIRECT_KEY] = redirect_url
|
||||
|
||||
if enroll_course_id:
|
||||
query_params[AUTH_ENROLL_COURSE_ID_KEY] = enroll_course_id
|
||||
|
||||
if email_opt_in:
|
||||
query_params[AUTH_EMAIL_OPT_IN_KEY] = email_opt_in
|
||||
|
||||
return u"{url}?{params}".format(
|
||||
url=url,
|
||||
params=urllib.urlencode(query_params)
|
||||
@@ -322,7 +314,7 @@ def get_disconnect_url(provider_name):
|
||||
return _get_url('social:disconnect', enabled_provider.BACKEND_CLASS.name)
|
||||
|
||||
|
||||
def get_login_url(provider_name, auth_entry, redirect_url=None, enroll_course_id=None, email_opt_in=None):
|
||||
def get_login_url(provider_name, auth_entry, redirect_url=None):
|
||||
"""Gets the login URL for the endpoint that kicks off auth with a provider.
|
||||
|
||||
Args:
|
||||
@@ -336,14 +328,6 @@ def get_login_url(provider_name, auth_entry, redirect_url=None, enroll_course_id
|
||||
redirect_url (string): If provided, redirect to this URL at the end
|
||||
of the authentication process.
|
||||
|
||||
enroll_course_id (string): If provided, auto-enroll the user in this
|
||||
course upon successful authentication.
|
||||
|
||||
email_opt_in (string): If set to 'true' (case insensitive), user will
|
||||
be opted into organization-wide email. Any other string will
|
||||
equate to False, and the user will be opted out of organization-wide
|
||||
email.
|
||||
|
||||
Returns:
|
||||
String. URL that starts the auth pipeline for a provider.
|
||||
|
||||
@@ -357,8 +341,6 @@ def get_login_url(provider_name, auth_entry, redirect_url=None, enroll_course_id
|
||||
enabled_provider.BACKEND_CLASS.name,
|
||||
auth_entry=auth_entry,
|
||||
redirect_url=redirect_url,
|
||||
enroll_course_id=enroll_course_id,
|
||||
email_opt_in=email_opt_in
|
||||
)
|
||||
|
||||
|
||||
@@ -445,13 +427,44 @@ def parse_query_params(strategy, response, *args, **kwargs):
|
||||
"""Reads whitelisted query params, transforms them into pipeline args."""
|
||||
auth_entry = strategy.session.get(AUTH_ENTRY_KEY)
|
||||
if not (auth_entry and auth_entry in _AUTH_ENTRY_CHOICES):
|
||||
raise AuthEntryError(strategy.backend, 'auth_entry missing or invalid')
|
||||
raise AuthEntryError(strategy.request.backend, 'auth_entry missing or invalid')
|
||||
|
||||
return {'auth_entry': auth_entry}
|
||||
|
||||
|
||||
def set_pipeline_timeout(strategy, user, *args, **kwargs):
|
||||
"""
|
||||
Set a short session timeout while the pipeline runs, to improve security.
|
||||
|
||||
Consider the following attack:
|
||||
1. Attacker on a public computer visits edX and initiates the third-party login flow
|
||||
2. Attacker logs into their own third-party account
|
||||
3. Attacker closes the window and does not complete the login flow
|
||||
4. Victim on the same computer logs into edX with username/password
|
||||
5. edX links attacker's third-party account with victim's edX account
|
||||
6. Attacker logs into victim's edX account using attacker's own third-party account
|
||||
|
||||
We have two features of the pipeline designed to prevent this attack:
|
||||
* This method shortens the Django session timeout during the pipeline. This should mean that
|
||||
if there is a reasonable delay between steps 3 and 4, the session and pipeline will be
|
||||
reset, and the attack foiled.
|
||||
Configure the timeout with the SOCIAL_AUTH_PIPELINE_TIMEOUT setting (Default: 600 seconds)
|
||||
* On step 4, the login page displays an obvious message to the user, saying "You've
|
||||
successfully signed into (Google), but your (Google) account isn't linked with an edX
|
||||
account. To link your accounts, login now using your edX password.".
|
||||
"""
|
||||
if strategy.request and not user: # If user is set, we're currently logged in (and/or linked) so it doesn't matter.
|
||||
strategy.request.session.set_expiry(strategy.setting('PIPELINE_TIMEOUT', 600))
|
||||
# We don't need to reset this timeout later. Because the user is not logged in and this
|
||||
# account is not yet linked to an edX account, either the normal 'login' or 'register'
|
||||
# code must occur during the subsequent ensure_user_information step, and those methods
|
||||
# will change the session timeout to the "normal" value according to the "Remember Me"
|
||||
# choice of the user.
|
||||
|
||||
|
||||
@partial.partial
|
||||
def ensure_user_information(strategy, auth_entry, user=None, *args, **kwargs):
|
||||
def ensure_user_information(strategy, auth_entry, backend=None, user=None, social=None,
|
||||
allow_inactive_user=False, *args, **kwargs):
|
||||
"""
|
||||
Ensure that we have the necessary information about a user (either an
|
||||
existing account or registration data) to proceed with the pipeline.
|
||||
@@ -470,63 +483,58 @@ def ensure_user_information(strategy, auth_entry, user=None, *args, **kwargs):
|
||||
# invariants have been violated and future misbehavior is likely.
|
||||
def dispatch_to_login():
|
||||
"""Redirects to the login page."""
|
||||
return redirect(_create_redirect_url(AUTH_DISPATCH_URLS[AUTH_ENTRY_LOGIN], strategy))
|
||||
return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_LOGIN])
|
||||
|
||||
def dispatch_to_register():
|
||||
"""Redirects to the registration page."""
|
||||
return redirect(_create_redirect_url(AUTH_DISPATCH_URLS[AUTH_ENTRY_REGISTER], strategy))
|
||||
return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_REGISTER])
|
||||
|
||||
user_inactive = user and not user.is_active
|
||||
|
||||
if auth_entry in [AUTH_ENTRY_LOGIN_API, AUTH_ENTRY_REGISTER_API]:
|
||||
if not user:
|
||||
if not user:
|
||||
if auth_entry in [AUTH_ENTRY_LOGIN_API, AUTH_ENTRY_REGISTER_API]:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
elif auth_entry in [AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN_2]:
|
||||
if not user or user_inactive:
|
||||
elif auth_entry in [AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN_2]:
|
||||
# User has authenticated with the third party provider but we don't know which edX
|
||||
# account corresponds to them yet, if any.
|
||||
return dispatch_to_login()
|
||||
|
||||
elif auth_entry in [AUTH_ENTRY_REGISTER, AUTH_ENTRY_REGISTER_2]:
|
||||
if not user:
|
||||
elif auth_entry in [AUTH_ENTRY_REGISTER, AUTH_ENTRY_REGISTER_2]:
|
||||
# User has authenticated with the third party provider and now wants to finish
|
||||
# creating their edX account.
|
||||
return dispatch_to_register()
|
||||
elif user_inactive:
|
||||
# If the user has a linked account, but has not yet activated
|
||||
# we should send them to the login page. The login page
|
||||
# will tell them that they need to activate their account.
|
||||
return dispatch_to_login()
|
||||
elif auth_entry == AUTH_ENTRY_ACCOUNT_SETTINGS:
|
||||
raise AuthEntryError(backend, 'auth_entry is wrong. Settings requires a user.')
|
||||
else:
|
||||
raise AuthEntryError(backend, 'auth_entry invalid')
|
||||
|
||||
if not user.is_active:
|
||||
# The user account has not been verified yet.
|
||||
if allow_inactive_user:
|
||||
# This parameter is used by the auth_exchange app, which always allows users to
|
||||
# login, whether or not their account is validated.
|
||||
pass
|
||||
# IF the user has just registered a new account as part of this pipeline, that is fine
|
||||
# and we allow the login to continue this once, because if we pause again to force the
|
||||
# user to activate their account via email, the pipeline may get lost (e.g. email takes
|
||||
# too long to arrive, user opens the activation email on a different device, etc.).
|
||||
# This is consistent with first party auth and ensures that the pipeline completes
|
||||
# fully, which is critical.
|
||||
# But if this is an existing account, we refuse to allow them to login again until they
|
||||
# check their email and activate the account.
|
||||
elif social is not None:
|
||||
# This third party account is already linked to a user account. That means that the
|
||||
# user's account existed before this pipeline originally began (since the creation
|
||||
# of the 'social' link entry occurs in one of the following pipeline steps).
|
||||
# Reject this login attempt and tell the user to validate their account first.
|
||||
|
||||
def _create_redirect_url(url, strategy):
|
||||
""" Given a URL and a Strategy, construct the appropriate redirect URL.
|
||||
# Send them another activation email:
|
||||
student.views.reactivation_email_for_user(user)
|
||||
|
||||
Construct a redirect URL and append the URL parameters that should be preserved.
|
||||
|
||||
Args:
|
||||
url (string): The base URL to use for the redirect.
|
||||
strategy (Strategy): Used to determine which URL parameters to append to the redirect.
|
||||
|
||||
Returns:
|
||||
A string representation of the URL, with parameters, for redirect.
|
||||
"""
|
||||
url_params = {}
|
||||
enroll_course_id = strategy.session_get(AUTH_ENROLL_COURSE_ID_KEY)
|
||||
if enroll_course_id:
|
||||
url_params['course_id'] = enroll_course_id
|
||||
url_params['enrollment_action'] = 'enroll'
|
||||
email_opt_in = strategy.session_get(AUTH_EMAIL_OPT_IN_KEY)
|
||||
if email_opt_in:
|
||||
url_params[AUTH_EMAIL_OPT_IN_KEY] = email_opt_in
|
||||
if url_params:
|
||||
return u'{url}?{params}'.format(
|
||||
url=url,
|
||||
params=urllib.urlencode(url_params)
|
||||
)
|
||||
else:
|
||||
return url
|
||||
raise NotActivatedException(backend, user.email)
|
||||
# else: The user must have just successfully registered their account, so we proceed.
|
||||
# We know they did not just login, because the login process rejects unverified users.
|
||||
|
||||
|
||||
@partial.partial
|
||||
def set_logged_in_cookie(backend=None, user=None, request=None, auth_entry=None, *args, **kwargs):
|
||||
def set_logged_in_cookie(backend=None, user=None, strategy=None, auth_entry=None, *args, **kwargs):
|
||||
"""This pipeline step sets the "logged in" cookie for authenticated users.
|
||||
|
||||
Some installations have a marketing site front-end separate from
|
||||
@@ -552,6 +560,8 @@ def set_logged_in_cookie(backend=None, user=None, request=None, auth_entry=None,
|
||||
|
||||
"""
|
||||
if not is_api(auth_entry) and user is not None and user.is_authenticated():
|
||||
request = strategy.request if strategy else None
|
||||
# n.b. for new users, user.is_active may be False at this point; set the cookie anyways.
|
||||
if request is not None:
|
||||
# Check that the cookie isn't already set.
|
||||
# This ensures that we allow the user to continue to the next
|
||||
@@ -587,7 +597,7 @@ def login_analytics(strategy, auth_entry, *args, **kwargs):
|
||||
event_name,
|
||||
{
|
||||
'category': "conversion",
|
||||
'label': strategy.session_get('enroll_course_id'),
|
||||
'label': None,
|
||||
'provider': getattr(kwargs['backend'], 'name')
|
||||
},
|
||||
context={
|
||||
@@ -599,100 +609,7 @@ def login_analytics(strategy, auth_entry, *args, **kwargs):
|
||||
|
||||
|
||||
@partial.partial
|
||||
def change_enrollment(strategy, auth_entry=None, user=None, *args, **kwargs):
|
||||
"""Enroll a user in a course.
|
||||
|
||||
If a user entered the authentication flow when trying to enroll
|
||||
in a course, then attempt to enroll the user.
|
||||
We will try to do this if the pipeline was started with the
|
||||
querystring param `enroll_course_id`.
|
||||
|
||||
In the following cases, we can't enroll the user:
|
||||
* The course does not have an honor mode.
|
||||
* The course has an honor mode with a minimum price.
|
||||
* The course is not yet open for enrollment.
|
||||
* The course does not exist.
|
||||
|
||||
If we can't enroll the user now, then skip this step.
|
||||
For paid courses, users will be redirected to the payment flow
|
||||
upon completion of the authentication pipeline
|
||||
(configured using the ?next parameter to the third party auth login url).
|
||||
|
||||
Keyword Arguments:
|
||||
auth_entry: The entry mode into the pipeline.
|
||||
user (User): The user being authenticated.
|
||||
"""
|
||||
# We skip enrollment if the user entered the flow from the "link account"
|
||||
# button on the account settings page. At this point, either:
|
||||
#
|
||||
# 1) The user already had a linked account when they started the enrollment flow,
|
||||
# in which case they would have been enrolled during the normal authentication process.
|
||||
#
|
||||
# 2) The user did NOT have a linked account, in which case they would have
|
||||
# needed to go through the login/register page. Since we preserve the querystring
|
||||
# args when sending users to this page, successfully authenticating through this page
|
||||
# would also enroll the student in the course.
|
||||
enroll_course_id = strategy.session_get('enroll_course_id')
|
||||
if enroll_course_id and auth_entry != AUTH_ENTRY_ACCOUNT_SETTINGS:
|
||||
course_id = CourseKey.from_string(enroll_course_id)
|
||||
modes = CourseMode.modes_for_course_dict(course_id)
|
||||
|
||||
# If the email opt in parameter is found, set the preference.
|
||||
email_opt_in = strategy.session_get(AUTH_EMAIL_OPT_IN_KEY)
|
||||
if email_opt_in:
|
||||
opt_in = email_opt_in.lower() == 'true'
|
||||
update_email_opt_in(user, course_id.org, opt_in)
|
||||
|
||||
# Check whether we're blocked from enrolling by a
|
||||
# country access rule.
|
||||
# Note: We skip checking the user's profile setting
|
||||
# for country here because the "redirect URL" pointing
|
||||
# to the blocked message page is set when the user
|
||||
# *enters* the pipeline, at which point they're
|
||||
# not authenticated. If they end up being blocked
|
||||
# from the courseware, it's better to let them
|
||||
# enroll and then show the message when they
|
||||
# enter the course than to skip enrollment
|
||||
# altogether.
|
||||
is_blocked = not embargo_api.check_course_access(
|
||||
course_id, ip_address=get_ip(strategy.request),
|
||||
url=strategy.request.path
|
||||
)
|
||||
if is_blocked:
|
||||
# If we're blocked, skip enrollment.
|
||||
# A redirect URL should have been set so the user
|
||||
# ends up on the embargo page when enrollment completes.
|
||||
pass
|
||||
|
||||
elif CourseMode.can_auto_enroll(course_id, modes_dict=modes):
|
||||
try:
|
||||
CourseEnrollment.enroll(user, course_id, check_access=True)
|
||||
except CourseEnrollmentException:
|
||||
pass
|
||||
except Exception as ex:
|
||||
logger.exception(ex)
|
||||
|
||||
# Handle white-label courses as a special case
|
||||
# If a course is white-label, we should add it to the shopping cart.
|
||||
elif CourseMode.is_white_label(course_id, modes_dict=modes):
|
||||
try:
|
||||
cart = Order.get_cart_for_user(user)
|
||||
PaidCourseRegistration.add_to_order(cart, course_id)
|
||||
except (
|
||||
CourseDoesNotExistException,
|
||||
ItemAlreadyInCartException,
|
||||
AlreadyEnrolledInCourseException,
|
||||
):
|
||||
pass
|
||||
# It's more important to complete login than to
|
||||
# ensure that the course was added to the shopping cart.
|
||||
# Log errors, but don't stop the authentication pipeline.
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
logger.exception(ex)
|
||||
|
||||
|
||||
@partial.partial
|
||||
def associate_by_email_if_login_api(auth_entry, strategy, details, user, *args, **kwargs):
|
||||
def associate_by_email_if_login_api(auth_entry, backend, details, user, *args, **kwargs):
|
||||
"""
|
||||
This pipeline step associates the current social auth with the user with the
|
||||
same email address in the database. It defers to the social library's associate_by_email
|
||||
@@ -701,7 +618,7 @@ def associate_by_email_if_login_api(auth_entry, strategy, details, user, *args,
|
||||
This association is done ONLY if the user entered the pipeline through a LOGIN API.
|
||||
"""
|
||||
if auth_entry == AUTH_ENTRY_LOGIN_API:
|
||||
association_response = associate_by_email(strategy, details, user, *args, **kwargs)
|
||||
association_response = associate_by_email(backend, details, user, *args, **kwargs)
|
||||
if (
|
||||
association_response and
|
||||
association_response.get('user') and
|
||||
|
||||
@@ -36,7 +36,7 @@ class BaseProvider(object):
|
||||
return '%s.%s' % (cls.BACKEND_CLASS.__module__, cls.BACKEND_CLASS.__name__)
|
||||
|
||||
@classmethod
|
||||
def get_email(cls, unused_provider_details):
|
||||
def get_email(cls, provider_details):
|
||||
"""Gets user's email address.
|
||||
|
||||
Provider responses can contain arbitrary data. This method can be
|
||||
@@ -44,16 +44,16 @@ class BaseProvider(object):
|
||||
extracted by the social_details pipeline step.
|
||||
|
||||
Args:
|
||||
unused_provider_details: dict of string -> string. Data about the
|
||||
provider_details: dict of string -> string. Data about the
|
||||
user passed back by the provider.
|
||||
|
||||
Returns:
|
||||
String or None. The user's email address, if any.
|
||||
"""
|
||||
return None
|
||||
return provider_details.get('email')
|
||||
|
||||
@classmethod
|
||||
def get_name(cls, unused_provider_details):
|
||||
def get_name(cls, provider_details):
|
||||
"""Gets user's name.
|
||||
|
||||
Provider responses can contain arbitrary data. This method can be
|
||||
@@ -61,13 +61,13 @@ class BaseProvider(object):
|
||||
extracted by the social_details pipeline step.
|
||||
|
||||
Args:
|
||||
unused_provider_details: dict of string -> string. Data about the
|
||||
provider_details: dict of string -> string. Data about the
|
||||
user passed back by the provider.
|
||||
|
||||
Returns:
|
||||
String or None. The user's full name, if any.
|
||||
"""
|
||||
return None
|
||||
return provider_details.get('fullname')
|
||||
|
||||
@classmethod
|
||||
def get_register_form_data(cls, pipeline_kwargs):
|
||||
@@ -121,14 +121,6 @@ class GoogleOauth2(BaseProvider):
|
||||
'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET': None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_email(cls, provider_details):
|
||||
return provider_details.get('email')
|
||||
|
||||
@classmethod
|
||||
def get_name(cls, provider_details):
|
||||
return provider_details.get('fullname')
|
||||
|
||||
|
||||
class LinkedInOauth2(BaseProvider):
|
||||
"""Provider for LinkedIn's Oauth2 auth system."""
|
||||
@@ -141,14 +133,6 @@ class LinkedInOauth2(BaseProvider):
|
||||
'SOCIAL_AUTH_LINKEDIN_OAUTH2_SECRET': None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_email(cls, provider_details):
|
||||
return provider_details.get('email')
|
||||
|
||||
@classmethod
|
||||
def get_name(cls, provider_details):
|
||||
return provider_details.get('fullname')
|
||||
|
||||
|
||||
class FacebookOauth2(BaseProvider):
|
||||
"""Provider for LinkedIn's Oauth2 auth system."""
|
||||
@@ -161,14 +145,6 @@ class FacebookOauth2(BaseProvider):
|
||||
'SOCIAL_AUTH_FACEBOOK_SECRET': None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_email(cls, provider_details):
|
||||
return provider_details.get('email')
|
||||
|
||||
@classmethod
|
||||
def get_name(cls, provider_details):
|
||||
return provider_details.get('fullname')
|
||||
|
||||
|
||||
class Registry(object):
|
||||
"""Singleton registry of third-party auth providers.
|
||||
|
||||
@@ -46,7 +46,7 @@ If true, it:
|
||||
from . import provider
|
||||
|
||||
|
||||
_FIELDS_STORED_IN_SESSION = ['auth_entry', 'next', 'enroll_course_id', 'email_opt_in']
|
||||
_FIELDS_STORED_IN_SESSION = ['auth_entry', 'next']
|
||||
_MIDDLEWARE_CLASSES = (
|
||||
'third_party_auth.middleware.ExceptionMiddleware',
|
||||
)
|
||||
@@ -105,6 +105,7 @@ def _set_global_settings(django_settings):
|
||||
'social.pipeline.social_auth.social_user',
|
||||
'third_party_auth.pipeline.associate_by_email_if_login_api',
|
||||
'social.pipeline.user.get_username',
|
||||
'third_party_auth.pipeline.set_pipeline_timeout',
|
||||
'third_party_auth.pipeline.ensure_user_information',
|
||||
'social.pipeline.user.create_user',
|
||||
'social.pipeline.social_auth.associate_user',
|
||||
@@ -112,7 +113,6 @@ def _set_global_settings(django_settings):
|
||||
'social.pipeline.user.user_details',
|
||||
'third_party_auth.pipeline.set_logged_in_cookie',
|
||||
'third_party_auth.pipeline.login_analytics',
|
||||
'third_party_auth.pipeline.change_enrollment',
|
||||
)
|
||||
|
||||
# We let the user specify their email address during signup.
|
||||
@@ -123,6 +123,13 @@ def _set_global_settings(django_settings):
|
||||
# enable this when you want to get stack traces rather than redirections.
|
||||
django_settings.SOCIAL_AUTH_RAISE_EXCEPTIONS = False
|
||||
|
||||
# Allow users to login using social auth even if their account is not verified yet
|
||||
# The 'ensure_user_information' step controls this and only allows brand new users
|
||||
# to login without verification. Repeat logins are not permitted until the account
|
||||
# gets verified.
|
||||
django_settings.INACTIVE_USER_LOGIN = True
|
||||
django_settings.INACTIVE_USER_URL = '/auth/inactive'
|
||||
|
||||
# Context processors required under Django.
|
||||
django_settings.SOCIAL_AUTH_UUID_LENGTH = 4
|
||||
django_settings.TEMPLATE_CONTEXT_PROCESSORS += (
|
||||
@@ -148,6 +155,9 @@ def _set_provider_settings(django_settings, enabled_providers, auth_info):
|
||||
|
||||
def apply_settings(auth_info, django_settings):
|
||||
"""Applies settings from auth_info dict to django_settings module."""
|
||||
if django_settings.FEATURES.get('ENABLE_DUMMY_THIRD_PARTY_AUTH_PROVIDER'):
|
||||
# The Dummy provider is handy for testing and development.
|
||||
from .dummy import DummyProvider # pylint: disable=unused-variable
|
||||
provider_names = auth_info.keys()
|
||||
provider.Registry.configure_once(provider_names)
|
||||
enabled_providers = provider.Registry.enabled()
|
||||
|
||||
@@ -140,7 +140,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
exception_middleware = middleware.ExceptionMiddleware()
|
||||
request, _ = self.get_request_and_strategy(auth_entry=auth_entry)
|
||||
response = exception_middleware.process_exception(
|
||||
request, exceptions.AuthCanceled(request.social_strategy.backend))
|
||||
request, exceptions.AuthCanceled(request.backend))
|
||||
location = response.get('Location')
|
||||
|
||||
self.assertEqual(302, response.status_code)
|
||||
@@ -161,7 +161,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
"""
|
||||
_, strategy = self.get_request_and_strategy(
|
||||
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
|
||||
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
self.create_user_models_for_existing_account(
|
||||
strategy, email, password, self.get_username(), skip_social_auth=True)
|
||||
|
||||
@@ -239,7 +239,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
def assert_redirect_to_dashboard_looks_correct(self, response):
|
||||
"""Asserts a response would redirect to /dashboard."""
|
||||
self.assertEqual(302, response.status_code)
|
||||
# pylint: disable-msg=protected-access
|
||||
# pylint: disable=protected-access
|
||||
self.assertEqual(auth_settings._SOCIAL_AUTH_LOGIN_REDIRECT_URL, response.get('Location'))
|
||||
|
||||
def assert_redirect_to_login_looks_correct(self, response):
|
||||
@@ -287,7 +287,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
See student.views.register and student.views._do_create_account.
|
||||
"""
|
||||
response_data = self.get_response_data()
|
||||
uid = strategy.backend.get_user_id(response_data, response_data)
|
||||
uid = strategy.request.backend.get_user_id(response_data, response_data)
|
||||
user = social_utils.Storage.user.create_user(email=email, password=password, username=username)
|
||||
profile = student_models.UserProfile(user=user)
|
||||
profile.save()
|
||||
@@ -310,7 +310,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
args = ()
|
||||
kwargs = {
|
||||
'request': strategy.request,
|
||||
'backend': strategy.backend,
|
||||
'backend': strategy.request.backend,
|
||||
'user': None,
|
||||
'response': self.get_response_data(),
|
||||
}
|
||||
@@ -355,8 +355,9 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
if auth_entry:
|
||||
request.session[pipeline.AUTH_ENTRY_KEY] = auth_entry
|
||||
|
||||
strategy = social_utils.load_strategy(backend=self.backend_name, redirect_uri=redirect_uri, request=request)
|
||||
strategy = social_utils.load_strategy(request=request)
|
||||
request.social_strategy = strategy
|
||||
request.backend = social_utils.load_backend(strategy, self.backend_name, redirect_uri)
|
||||
|
||||
return request, strategy
|
||||
|
||||
@@ -404,7 +405,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
# configure the backend, and mock out wire traffic.
|
||||
request, strategy = self.get_request_and_strategy(
|
||||
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
|
||||
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
pipeline.analytics.track = mock.MagicMock()
|
||||
request.user = self.create_user_models_for_existing_account(
|
||||
strategy, 'user@example.com', 'password', self.get_username(), skip_social_auth=True)
|
||||
@@ -413,12 +414,12 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
# expected state.
|
||||
self.client.get(
|
||||
pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))
|
||||
actions.do_complete(strategy, social_views._do_login) # pylint: disable-msg=protected-access
|
||||
actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access
|
||||
|
||||
mako_middleware_process_request(strategy.request)
|
||||
student_views.signin_user(strategy.request)
|
||||
student_views.login_user(strategy.request)
|
||||
actions.do_complete(strategy, social_views._do_login) # pylint: disable-msg=protected-access
|
||||
actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access
|
||||
|
||||
# First we expect that we're in the unlinked state, and that there
|
||||
# really is no association in the backend.
|
||||
@@ -428,7 +429,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
# We should be redirected back to the complete page, setting
|
||||
# the "logged in" cookie for the marketing site.
|
||||
self.assert_logged_in_cookie_redirect(actions.do_complete(
|
||||
request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access
|
||||
request.backend, social_views._do_login, request.user, None, # pylint: disable=protected-access
|
||||
redirect_field_name=auth.REDIRECT_FIELD_NAME
|
||||
))
|
||||
|
||||
@@ -437,7 +438,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
|
||||
# Fire off the auth pipeline to link.
|
||||
self.assert_redirect_to_dashboard_looks_correct(actions.do_complete(
|
||||
request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access
|
||||
request.backend, social_views._do_login, request.user, None, # pylint: disable=protected-access
|
||||
redirect_field_name=auth.REDIRECT_FIELD_NAME))
|
||||
|
||||
# Now we expect to be in the linked state, with a backend entry.
|
||||
@@ -449,7 +450,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
# configure the backend, and mock out wire traffic.
|
||||
request, strategy = self.get_request_and_strategy(
|
||||
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
|
||||
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
user = self.create_user_models_for_existing_account(
|
||||
strategy, 'user@example.com', 'password', self.get_username())
|
||||
self.assert_social_auth_exists_for_user(user, strategy)
|
||||
@@ -461,12 +462,12 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
# expected state.
|
||||
self.client.get(
|
||||
pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))
|
||||
actions.do_complete(strategy, social_views._do_login) # pylint: disable-msg=protected-access
|
||||
actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access
|
||||
|
||||
mako_middleware_process_request(strategy.request)
|
||||
student_views.signin_user(strategy.request)
|
||||
student_views.login_user(strategy.request)
|
||||
actions.do_complete(strategy, social_views._do_login, user=user) # pylint: disable-msg=protected-access
|
||||
actions.do_complete(request.backend, social_views._do_login, user=user) # pylint: disable=protected-access
|
||||
|
||||
# First we expect that we're in the linked state, with a backend entry.
|
||||
self.assert_account_settings_context_looks_correct(account_settings_context(request), user, linked=True)
|
||||
@@ -474,7 +475,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
|
||||
# Fire off the disconnect pipeline to unlink.
|
||||
self.assert_redirect_to_dashboard_looks_correct(actions.do_disconnect(
|
||||
request.social_strategy, request.user, None, redirect_field_name=auth.REDIRECT_FIELD_NAME))
|
||||
request.backend, request.user, None, redirect_field_name=auth.REDIRECT_FIELD_NAME))
|
||||
|
||||
# Now we expect to be in the unlinked state, with no backend entry.
|
||||
self.assert_account_settings_context_looks_correct(account_settings_context(request), user, linked=False)
|
||||
@@ -490,7 +491,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
username = self.get_username()
|
||||
_, strategy = self.get_request_and_strategy(
|
||||
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
|
||||
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
backend = strategy.request.backend
|
||||
backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
linked_user = self.create_user_models_for_existing_account(strategy, email, password, username)
|
||||
unlinked_user = social_utils.Storage.user.create_user(
|
||||
email='other_' + email, password=password, username='other_' + username)
|
||||
@@ -499,7 +501,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
self.assert_social_auth_does_not_exist_for_user(unlinked_user, strategy)
|
||||
|
||||
with self.assertRaises(exceptions.AuthAlreadyAssociated):
|
||||
actions.do_complete(strategy, social_views._do_login, user=unlinked_user) # pylint: disable-msg=protected-access
|
||||
# pylint: disable=protected-access
|
||||
actions.do_complete(backend, social_views._do_login, user=unlinked_user)
|
||||
|
||||
def test_already_associated_exception_populates_dashboard_with_error(self):
|
||||
# Instrument the pipeline with an exception. We test that the
|
||||
@@ -511,21 +514,21 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
# that the duplicate error has no effect on the state of the controls.
|
||||
request, strategy = self.get_request_and_strategy(
|
||||
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
|
||||
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
user = self.create_user_models_for_existing_account(
|
||||
strategy, 'user@example.com', 'password', self.get_username())
|
||||
self.assert_social_auth_exists_for_user(user, strategy)
|
||||
|
||||
self.client.get('/login')
|
||||
self.client.get(pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))
|
||||
actions.do_complete(strategy, social_views._do_login) # pylint: disable-msg=protected-access
|
||||
actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access
|
||||
|
||||
mako_middleware_process_request(strategy.request)
|
||||
student_views.signin_user(strategy.request)
|
||||
student_views.login_user(strategy.request)
|
||||
actions.do_complete(strategy, social_views._do_login, user=user) # pylint: disable-msg=protected-access
|
||||
actions.do_complete(request.backend, social_views._do_login, user=user) # pylint: disable=protected-access
|
||||
|
||||
# Monkey-patch storage for messaging; pylint: disable-msg=protected-access
|
||||
# Monkey-patch storage for messaging; pylint: disable=protected-access
|
||||
request._messages = fallback.FallbackStorage(request)
|
||||
middleware.ExceptionMiddleware().process_exception(
|
||||
request,
|
||||
@@ -539,7 +542,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
# configure the backend, and mock out wire traffic.
|
||||
request, strategy = self.get_request_and_strategy(
|
||||
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
|
||||
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
pipeline.analytics.track = mock.MagicMock()
|
||||
user = self.create_user_models_for_existing_account(
|
||||
strategy, 'user@example.com', 'password', self.get_username())
|
||||
@@ -558,8 +561,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
|
||||
# Next, the provider makes a request against /auth/complete/<provider>
|
||||
# to resume the pipeline.
|
||||
# pylint: disable-msg=protected-access
|
||||
self.assert_redirect_to_login_looks_correct(actions.do_complete(strategy, social_views._do_login))
|
||||
# pylint: disable=protected-access
|
||||
self.assert_redirect_to_login_looks_correct(actions.do_complete(request.backend, social_views._do_login))
|
||||
|
||||
mako_middleware_process_request(strategy.request)
|
||||
# At this point we know the pipeline has resumed correctly. Next we
|
||||
@@ -574,7 +577,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
# We should be redirected back to the complete page, setting
|
||||
# the "logged in" cookie for the marketing site.
|
||||
self.assert_logged_in_cookie_redirect(actions.do_complete(
|
||||
request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access
|
||||
request.backend, social_views._do_login, request.user, None, # pylint: disable=protected-access
|
||||
redirect_field_name=auth.REDIRECT_FIELD_NAME
|
||||
))
|
||||
|
||||
@@ -582,13 +585,13 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
self.set_logged_in_cookie(request)
|
||||
|
||||
self.assert_redirect_to_dashboard_looks_correct(
|
||||
actions.do_complete(strategy, social_views._do_login, user=user))
|
||||
actions.do_complete(request.backend, social_views._do_login, user=user))
|
||||
self.assert_account_settings_context_looks_correct(account_settings_context(request), user)
|
||||
|
||||
def test_signin_fails_if_account_not_active(self):
|
||||
_, strategy = self.get_request_and_strategy(
|
||||
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
|
||||
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
user = self.create_user_models_for_existing_account(strategy, 'user@example.com', 'password', self.get_username())
|
||||
|
||||
user.is_active = False
|
||||
@@ -600,7 +603,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
def test_signin_fails_if_no_account_associated(self):
|
||||
_, strategy = self.get_request_and_strategy(
|
||||
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
|
||||
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
self.create_user_models_for_existing_account(
|
||||
strategy, 'user@example.com', 'password', self.get_username(), skip_social_auth=True)
|
||||
|
||||
@@ -625,7 +628,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
# Mock out wire traffic.
|
||||
request, strategy = self.get_request_and_strategy(
|
||||
auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete')
|
||||
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
|
||||
# Begin! Grab the registration page and check the login control on it.
|
||||
self.assert_register_response_before_pipeline_looks_correct(self.client.get('/register'))
|
||||
@@ -637,8 +640,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN)))
|
||||
|
||||
# Next, the provider makes a request against /auth/complete/<provider>.
|
||||
# pylint: disable-msg=protected-access
|
||||
self.assert_redirect_to_register_looks_correct(actions.do_complete(strategy, social_views._do_login))
|
||||
# pylint: disable=protected-access
|
||||
self.assert_redirect_to_register_looks_correct(actions.do_complete(request.backend, social_views._do_login))
|
||||
|
||||
mako_middleware_process_request(strategy.request)
|
||||
# At this point we know the pipeline has resumed correctly. Next we
|
||||
@@ -672,33 +675,18 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
# social auth.
|
||||
self.assert_social_auth_does_not_exist_for_user(created_user, strategy)
|
||||
|
||||
# Since the user's account is not yet active, we should be redirected to /login
|
||||
self.assert_redirect_to_login_looks_correct(
|
||||
actions.do_complete(
|
||||
request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access
|
||||
redirect_field_name=auth.REDIRECT_FIELD_NAME
|
||||
)
|
||||
)
|
||||
|
||||
# Activate the user's account
|
||||
strategy.request.user.is_active = True
|
||||
strategy.request.user.save()
|
||||
|
||||
# Try again. This time, we should be redirected back to the complete page, setting
|
||||
# We should be redirected back to the complete page, setting
|
||||
# the "logged in" cookie for the marketing site.
|
||||
self.assert_logged_in_cookie_redirect(actions.do_complete(
|
||||
request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access
|
||||
request.backend, social_views._do_login, request.user, None, # pylint: disable=protected-access
|
||||
redirect_field_name=auth.REDIRECT_FIELD_NAME
|
||||
))
|
||||
|
||||
# Set the cookie and try again
|
||||
self.set_logged_in_cookie(request)
|
||||
|
||||
# Pick the pipeline back up. This will create the account association
|
||||
# and send the user to the dashboard, where the association will be
|
||||
# displayed.
|
||||
self.assert_redirect_to_dashboard_looks_correct(
|
||||
actions.do_complete(strategy, social_views._do_login, user=created_user))
|
||||
actions.do_complete(strategy.request.backend, social_views._do_login, user=created_user))
|
||||
# Now the user has been redirected to the dashboard. Their third party account should now be linked.
|
||||
self.assert_social_auth_exists_for_user(created_user, strategy)
|
||||
self.assert_account_settings_context_looks_correct(account_settings_context(request), created_user, linked=True)
|
||||
|
||||
@@ -710,18 +698,20 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
# Create a colliding username in the backend, then proceed with
|
||||
# assignment via pipeline to make sure a distinct username is created.
|
||||
strategy.storage.user.create_user(username=self.get_username(), email='user@email.com', password='password')
|
||||
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
# pylint: disable-msg=protected-access
|
||||
self.assert_redirect_to_register_looks_correct(actions.do_complete(strategy, social_views._do_login))
|
||||
backend = strategy.request.backend
|
||||
backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
# pylint: disable=protected-access
|
||||
self.assert_redirect_to_register_looks_correct(actions.do_complete(backend, social_views._do_login))
|
||||
distinct_username = pipeline.get(request)['kwargs']['username']
|
||||
self.assertNotEqual(original_username, distinct_username)
|
||||
|
||||
def test_new_account_registration_fails_if_email_exists(self):
|
||||
request, strategy = self.get_request_and_strategy(
|
||||
auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete')
|
||||
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
# pylint: disable-msg=protected-access
|
||||
self.assert_redirect_to_register_looks_correct(actions.do_complete(strategy, social_views._do_login))
|
||||
backend = strategy.request.backend
|
||||
backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
# pylint: disable=protected-access
|
||||
self.assert_redirect_to_register_looks_correct(actions.do_complete(backend, social_views._do_login))
|
||||
|
||||
mako_middleware_process_request(strategy.request)
|
||||
self.assert_register_response_in_pipeline_looks_correct(
|
||||
@@ -733,21 +723,21 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
|
||||
def test_pipeline_raises_auth_entry_error_if_auth_entry_invalid(self):
|
||||
auth_entry = 'invalid'
|
||||
self.assertNotIn(auth_entry, pipeline._AUTH_ENTRY_CHOICES) # pylint: disable-msg=protected-access
|
||||
self.assertNotIn(auth_entry, pipeline._AUTH_ENTRY_CHOICES) # pylint: disable=protected-access
|
||||
|
||||
_, strategy = self.get_request_and_strategy(auth_entry=auth_entry, redirect_uri='social:complete')
|
||||
|
||||
with self.assertRaises(pipeline.AuthEntryError):
|
||||
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
|
||||
def test_pipeline_raises_auth_entry_error_if_auth_entry_missing(self):
|
||||
_, strategy = self.get_request_and_strategy(auth_entry=None, redirect_uri='social:complete')
|
||||
|
||||
with self.assertRaises(pipeline.AuthEntryError):
|
||||
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
|
||||
|
||||
|
||||
class Oauth2IntegrationTest(IntegrationTest): # pylint: disable-msg=abstract-method
|
||||
class Oauth2IntegrationTest(IntegrationTest): # pylint: disable=abstract-method
|
||||
"""Base test case for integration tests of Oauth2 providers."""
|
||||
|
||||
# Dict of string -> object. Information about the token granted to the
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for the change enrollment step of the pipeline. """
|
||||
from collections import namedtuple
|
||||
|
||||
import datetime
|
||||
import unittest
|
||||
from mock import patch
|
||||
import ddt
|
||||
import pytz
|
||||
from util.testing import UrlResetMixin
|
||||
from third_party_auth import pipeline
|
||||
from shoppingcart.models import Order, PaidCourseRegistration # pylint: disable=import-error
|
||||
from social.apps.django_app import utils as social_utils
|
||||
from django.conf import settings
|
||||
from django.contrib.sessions.backends import cache
|
||||
from django.test import RequestFactory
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from student.tests.factories import UserFactory, CourseModeFactory
|
||||
from student.models import CourseEnrollment
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from openedx.core.djangoapps.user_api.models import UserOrgTag
|
||||
from embargo.test_utils import restrict_course
|
||||
|
||||
|
||||
THIRD_PARTY_AUTH_CONFIGURED = (
|
||||
settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH') and
|
||||
getattr(settings, 'THIRD_PARTY_AUTH', {})
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(THIRD_PARTY_AUTH_CONFIGURED, "Third party auth must be configured")
|
||||
@patch.dict(settings.FEATURES, {'EMBARGO': True})
|
||||
@ddt.ddt
|
||||
class PipelineEnrollmentTest(UrlResetMixin, ModuleStoreTestCase):
|
||||
"""Test that the pipeline auto-enrolls students upon successful authentication. """
|
||||
|
||||
BACKEND_NAME = "google-oauth2"
|
||||
|
||||
@patch.dict(settings.FEATURES, {'EMBARGO': True})
|
||||
def setUp(self):
|
||||
"""Create a test course and user. """
|
||||
super(PipelineEnrollmentTest, self).setUp('embargo')
|
||||
self.course = CourseFactory.create()
|
||||
self.user = UserFactory.create()
|
||||
|
||||
@ddt.data(
|
||||
([], "honor", u"False", u"False"),
|
||||
(["honor", "verified", "audit"], "honor", u"True", u"True"),
|
||||
(["professional"], None, u"Fålsœ", u"False")
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_auto_enroll_step(self, course_modes, enrollment_mode, email_opt_in, email_opt_in_result):
|
||||
# Create the course modes for the test case
|
||||
for mode_slug in course_modes:
|
||||
CourseModeFactory.create(
|
||||
course_id=self.course.id,
|
||||
mode_slug=mode_slug,
|
||||
mode_display_name=mode_slug.capitalize()
|
||||
)
|
||||
|
||||
# Simulate the pipeline step, passing in a course ID
|
||||
# to indicate that the user was trying to enroll
|
||||
# when they started the auth process.
|
||||
strategy = self._fake_strategy()
|
||||
strategy.session_set('enroll_course_id', unicode(self.course.id))
|
||||
strategy.session_set('email_opt_in', email_opt_in)
|
||||
|
||||
result = pipeline.change_enrollment(strategy, 1, user=self.user) # pylint: disable=assignment-from-no-return,redundant-keyword-arg
|
||||
self.assertEqual(result, {})
|
||||
|
||||
# Check that the user was or was not enrolled
|
||||
# (this will vary based on the course mode)
|
||||
if enrollment_mode is not None:
|
||||
actual_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id)
|
||||
self.assertTrue(is_active)
|
||||
self.assertEqual(actual_mode, enrollment_mode)
|
||||
else:
|
||||
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
|
||||
|
||||
# Check that the Email Opt In option was set
|
||||
tag = UserOrgTag.objects.get(user=self.user)
|
||||
self.assertIsNotNone(tag)
|
||||
self.assertEquals(tag.value, email_opt_in_result)
|
||||
|
||||
def test_add_white_label_to_cart(self):
|
||||
# Create a white label course (honor with a minimum price)
|
||||
CourseModeFactory.create(
|
||||
course_id=self.course.id,
|
||||
mode_slug="honor",
|
||||
mode_display_name="Honor",
|
||||
min_price=100
|
||||
)
|
||||
|
||||
# Simulate the pipeline step for enrolling in this course
|
||||
strategy = self._fake_strategy()
|
||||
strategy.session_set('enroll_course_id', unicode(self.course.id))
|
||||
result = pipeline.change_enrollment(strategy, 1, user=self.user) # pylint: disable=assignment-from-no-return,redundant-keyword-arg
|
||||
self.assertEqual(result, {})
|
||||
|
||||
# Expect that the uesr is NOT enrolled in the course
|
||||
# because the user has not yet paid
|
||||
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
|
||||
|
||||
# Expect that the course was added to the shopping cart
|
||||
cart = Order.get_cart_for_user(self.user)
|
||||
self.assertTrue(cart.has_items(PaidCourseRegistration))
|
||||
order_item = PaidCourseRegistration.objects.get(order=cart)
|
||||
self.assertEqual(order_item.course_id, self.course.id)
|
||||
|
||||
def test_auto_enroll_not_accessible(self):
|
||||
# Set the course open date in the future
|
||||
tomorrow = datetime.datetime.now(pytz.utc) + datetime.timedelta(days=1)
|
||||
self.course.enrollment_start = tomorrow
|
||||
self.update_course(self.course, self.user.id)
|
||||
|
||||
# Finish authentication and try to auto-enroll
|
||||
# This should fail silently, with no exception
|
||||
strategy = self._fake_strategy()
|
||||
strategy.session_set('enroll_course_id', unicode(self.course.id))
|
||||
result = pipeline.change_enrollment(strategy, 1, user=self.user) # pylint: disable=assignment-from-no-return,redundant-keyword-arg
|
||||
self.assertEqual(result, {})
|
||||
|
||||
# Verify that we were NOT enrolled
|
||||
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
|
||||
|
||||
def test_no_course_id_skips_enroll(self):
|
||||
strategy = self._fake_strategy()
|
||||
result = pipeline.change_enrollment(strategy, 1, user=self.user) # pylint: disable=assignment-from-no-return,redundant-keyword-arg
|
||||
self.assertEqual(result, {})
|
||||
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
|
||||
|
||||
@patch.dict(settings.FEATURES, {'EMBARGO': True})
|
||||
def test_blocked_by_embargo(self):
|
||||
strategy = self._fake_strategy()
|
||||
strategy.session_set('enroll_course_id', unicode(self.course.id))
|
||||
|
||||
with restrict_course(self.course.id):
|
||||
result = pipeline.change_enrollment(strategy, 1, user=self.user) # pylint: disable=assignment-from-no-return,redundant-keyword-arg
|
||||
|
||||
# Verify that we were NOT enrolled
|
||||
self.assertEqual(result, {})
|
||||
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
|
||||
|
||||
def test_skip_enroll_from_dashboard(self):
|
||||
strategy = self._fake_strategy()
|
||||
strategy.session_set('enroll_course_id', unicode(self.course.id))
|
||||
|
||||
# Simulate completing the pipeline from the student account settings
|
||||
# "link account" button.
|
||||
result = pipeline.change_enrollment(strategy, 1, user=self.user, auth_entry=pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS) # pylint: disable=assignment-from-no-return,redundant-keyword-arg
|
||||
|
||||
# Verify that we were NOT enrolled
|
||||
self.assertEqual(result, {})
|
||||
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
|
||||
|
||||
def test_url_creation(self):
|
||||
strategy = self._fake_strategy()
|
||||
strategy.session_set('enroll_course_id', unicode(self.course.id))
|
||||
strategy.session_set('email_opt_in', u"False")
|
||||
backend = namedtuple('backend', 'name')
|
||||
backend.name = self.BACKEND_NAME
|
||||
response = pipeline.ensure_user_information(
|
||||
strategy=strategy,
|
||||
pipeline_index=1,
|
||||
details=None,
|
||||
response=None,
|
||||
uid=None,
|
||||
auth_entry=pipeline.AUTH_ENTRY_REGISTER,
|
||||
backend=backend
|
||||
)
|
||||
self.assertIsNotNone(response)
|
||||
self.assertEquals(response.status_code, 302)
|
||||
|
||||
# Get the location
|
||||
_, url = response._headers['location'] # pylint: disable=W0212
|
||||
self.assertIn("email_opt_in=False", url)
|
||||
self.assertIn("course_id=".format(id=unicode(self.course.id)), url)
|
||||
|
||||
def _fake_strategy(self):
|
||||
"""Simulate the strategy passed to the pipeline step. """
|
||||
request = RequestFactory().get(pipeline.get_complete_url(self.BACKEND_NAME))
|
||||
request.user = self.user
|
||||
request.session = cache.SessionStore()
|
||||
|
||||
return social_utils.load_strategy(
|
||||
backend=self.BACKEND_NAME, request=request
|
||||
)
|
||||
@@ -66,7 +66,7 @@ class ThirdPartyOAuthTestMixin(object):
|
||||
class ThirdPartyOAuthTestMixinFacebook(object):
|
||||
"""Tests oauth with the Facebook backend"""
|
||||
BACKEND = "facebook"
|
||||
USER_URL = "https://graph.facebook.com/me"
|
||||
USER_URL = "https://graph.facebook.com/v2.3/me"
|
||||
# In facebook responses, the "id" field is used as the user's identifier
|
||||
UID_FIELD = "id"
|
||||
|
||||
@@ -74,6 +74,6 @@ class ThirdPartyOAuthTestMixinFacebook(object):
|
||||
class ThirdPartyOAuthTestMixinGoogle(object):
|
||||
"""Tests oauth with the Google backend"""
|
||||
BACKEND = "google-oauth2"
|
||||
USER_URL = "https://www.googleapis.com/oauth2/v1/userinfo"
|
||||
USER_URL = "https://www.googleapis.com/plus/v1/people/me"
|
||||
# In google-oauth2 responses, the "email" field is used as the user's identifier
|
||||
UID_FIELD = "email"
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
from django.conf.urls import include, patterns, url
|
||||
|
||||
from .views import inactive_user_view
|
||||
|
||||
urlpatterns = patterns(
|
||||
'',
|
||||
url(r'^auth/inactive', inactive_user_view),
|
||||
url(r'^auth/', include('social.apps.django_app.urls', namespace='social')),
|
||||
)
|
||||
|
||||
15
common/djangoapps/third_party_auth/views.py
Normal file
15
common/djangoapps/third_party_auth/views.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Extra views required for SSO
|
||||
"""
|
||||
from django.shortcuts import redirect
|
||||
|
||||
|
||||
def inactive_user_view(request):
|
||||
"""
|
||||
A newly registered user has completed the social auth pipeline.
|
||||
Their account is not yet activated, but we let them login this once.
|
||||
"""
|
||||
# 'next' may be set to '/account/finish_auth/.../' if this user needs to be auto-enrolled
|
||||
# in a course. Otherwise, just redirect them to the dashboard, which displays a message
|
||||
# about activating their account.
|
||||
return redirect(request.GET.get('next', 'dashboard'))
|
||||
@@ -80,7 +80,7 @@ class FieldsMixin(object):
|
||||
query = self.q(css='.u-field-{} .u-field-message'.format(field_id))
|
||||
return query.text[0] if query.present else None
|
||||
|
||||
def wait_for_messsage(self, field_id, message):
|
||||
def wait_for_message(self, field_id, message):
|
||||
"""
|
||||
Wait for a message to appear in a field.
|
||||
"""
|
||||
|
||||
@@ -187,10 +187,14 @@ class CombinedLoginAndRegisterPage(PageObject):
|
||||
"""
|
||||
# Fill in the form
|
||||
self.wait_for_element_visibility('#register-email', 'Email field is shown')
|
||||
self.q(css="#register-email").fill(email)
|
||||
self.q(css="#register-name").fill(full_name)
|
||||
self.q(css="#register-username").fill(username)
|
||||
self.q(css="#register-password").fill(password)
|
||||
if email:
|
||||
self.q(css="#register-email").fill(email)
|
||||
if full_name:
|
||||
self.q(css="#register-name").fill(full_name)
|
||||
if username:
|
||||
self.q(css="#register-username").fill(username)
|
||||
if password:
|
||||
self.q(css="#register-password").fill(password)
|
||||
if country:
|
||||
self.q(css="#register-country option[value='{country}']".format(country=country)).click()
|
||||
if (terms_of_service):
|
||||
@@ -220,6 +224,16 @@ class CombinedLoginAndRegisterPage(PageObject):
|
||||
# Submit it
|
||||
self.q(css=".login-button").click()
|
||||
|
||||
def click_third_party_dummy_provider(self):
|
||||
"""Clicks on the Dummy third party provider login button.
|
||||
|
||||
Requires that the "login" form is visible.
|
||||
This does NOT wait for the ensuing page[s] to load.
|
||||
Only the "Dummy" provider is used for bok choy because it is the only
|
||||
one that doesn't send traffic to external servers.
|
||||
"""
|
||||
self.q(css="button.{}-Dummy".format(self.current_form)).click()
|
||||
|
||||
def password_reset(self, email):
|
||||
"""Navigates to, fills in, and submits the password reset form.
|
||||
|
||||
@@ -268,6 +282,21 @@ class CombinedLoginAndRegisterPage(PageObject):
|
||||
elif self.q(css=".js-reset").visible:
|
||||
return "password-reset"
|
||||
|
||||
@property
|
||||
def email_value(self):
|
||||
""" Current value of the email form field """
|
||||
return self.q(css="#register-email").attrs('value')[0]
|
||||
|
||||
@property
|
||||
def full_name_value(self):
|
||||
""" Current value of the full_name form field """
|
||||
return self.q(css="#register-name").attrs('value')[0]
|
||||
|
||||
@property
|
||||
def username_value(self):
|
||||
""" Current value of the username form field """
|
||||
return self.q(css="#register-username").attrs('value')[0]
|
||||
|
||||
@property
|
||||
def errors(self):
|
||||
"""Return a list of errors displayed to the user. """
|
||||
@@ -294,3 +323,15 @@ class CombinedLoginAndRegisterPage(PageObject):
|
||||
success = self.success
|
||||
return (bool(success), success)
|
||||
return Promise(_check_func, "Success message is visible").fulfill()
|
||||
|
||||
@unguarded # Because we go from this page -> temporary page -> this page again when testing the Dummy provider
|
||||
def wait_for_auth_status_message(self):
|
||||
"""Wait for a status message to be visible following third_party registration, then return it."""
|
||||
def _check_func():
|
||||
"""Return third party auth status notice message."""
|
||||
for selector in ['.already-authenticated-msg p', '.status p']:
|
||||
msg_element = self.q(css=selector)
|
||||
if msg_element.visible:
|
||||
return (True, msg_element.text[0])
|
||||
return (False, None)
|
||||
return Promise(_check_func, "Result of third party auth is visible").fulfill()
|
||||
|
||||
@@ -177,6 +177,7 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest):
|
||||
{
|
||||
'title': 'Connected Accounts',
|
||||
'fields': [
|
||||
'Dummy',
|
||||
'Facebook',
|
||||
'Google',
|
||||
]
|
||||
@@ -211,7 +212,7 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest):
|
||||
|
||||
for new_value in new_valid_values:
|
||||
self.assertEqual(self.account_settings_page.value_for_text_field(field_id, new_value), new_value)
|
||||
self.account_settings_page.wait_for_messsage(field_id, success_message)
|
||||
self.account_settings_page.wait_for_message(field_id, success_message)
|
||||
if assert_after_reload:
|
||||
self.browser.refresh()
|
||||
self.assertEqual(self.account_settings_page.value_for_text_field(field_id), new_value)
|
||||
@@ -227,7 +228,7 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest):
|
||||
|
||||
for new_value in new_values:
|
||||
self.assertEqual(self.account_settings_page.value_for_dropdown_field(field_id, new_value), new_value)
|
||||
self.account_settings_page.wait_for_messsage(field_id, success_message)
|
||||
self.account_settings_page.wait_for_message(field_id, success_message)
|
||||
if reloads_on_save:
|
||||
self.account_settings_page.wait_for_loading_indicator()
|
||||
else:
|
||||
@@ -242,7 +243,7 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest):
|
||||
self.assertEqual(self.account_settings_page.title_for_field(field_id), title)
|
||||
self.assertEqual(self.account_settings_page.link_title_for_link_field(field_id), link_title)
|
||||
self.account_settings_page.click_on_link_in_link_field(field_id)
|
||||
self.account_settings_page.wait_for_messsage(field_id, success_message)
|
||||
self.account_settings_page.wait_for_message(field_id, success_message)
|
||||
|
||||
def test_username_field(self):
|
||||
"""
|
||||
|
||||
@@ -18,6 +18,7 @@ from ..helpers import (
|
||||
select_option_by_value,
|
||||
element_has_text
|
||||
)
|
||||
from ...pages.lms.account_settings import AccountSettingsPage
|
||||
from ...pages.lms.auto_auth import AutoAuthPage
|
||||
from ...pages.lms.create_mode import ModeCreationPage
|
||||
from ...pages.common.logout import LogoutPage
|
||||
@@ -131,6 +132,46 @@ class LoginFromCombinedPageTest(UniqueCourseTest):
|
||||
self.login_page.wait_for_errors()
|
||||
)
|
||||
|
||||
def test_third_party_login(self):
|
||||
"""
|
||||
Test that we can login using third party credentials, and that the
|
||||
third party account gets linked to the edX account.
|
||||
"""
|
||||
# Create a user account
|
||||
email, password = self._create_unique_user()
|
||||
|
||||
# Navigate to the login page and try to log in using "Dummy" provider
|
||||
self.login_page.visit()
|
||||
self.login_page.click_third_party_dummy_provider()
|
||||
|
||||
# The user will be redirected somewhere and then back to the login page:
|
||||
msg_text = self.login_page.wait_for_auth_status_message()
|
||||
self.assertIn("You have successfully signed into Dummy", msg_text)
|
||||
self.assertIn("To link your accounts, sign in now using your edX password", msg_text)
|
||||
|
||||
# Now login with username and password:
|
||||
self.login_page.login(email=email, password=password)
|
||||
|
||||
# Expect that we reach the dashboard and we're auto-enrolled in the course
|
||||
course_names = self.dashboard_page.wait_for_page().available_courses
|
||||
self.assertIn(self.course_info["display_name"], course_names)
|
||||
|
||||
# Now logout and check that we can log back in instantly (because the account is linked):
|
||||
LogoutPage(self.browser).visit()
|
||||
|
||||
self.login_page.visit()
|
||||
self.login_page.click_third_party_dummy_provider()
|
||||
|
||||
self.dashboard_page.wait_for_page()
|
||||
|
||||
# Now unlink the account (To test the account settings view and also to prevent cross-test side effects)
|
||||
account_settings = AccountSettingsPage(self.browser).visit()
|
||||
field_id = "auth-dummy"
|
||||
account_settings.wait_for_field(field_id)
|
||||
self.assertEqual("Unlink", account_settings.link_title_for_link_field(field_id))
|
||||
account_settings.click_on_link_in_link_field(field_id)
|
||||
account_settings.wait_for_message(field_id, "Successfully unlinked")
|
||||
|
||||
def _create_unique_user(self):
|
||||
"""
|
||||
Create a new user with a unique name and email.
|
||||
@@ -226,6 +267,50 @@ class RegisterFromCombinedPageTest(UniqueCourseTest):
|
||||
self.register_page.visit().toggle_form()
|
||||
self.assertEqual(self.register_page.current_form, "login")
|
||||
|
||||
def test_third_party_register(self):
|
||||
"""
|
||||
Test that we can register using third party credentials, and that the
|
||||
third party account gets linked to the edX account.
|
||||
"""
|
||||
# Navigate to the register page and try to authenticate using the "Dummy" provider
|
||||
self.register_page.visit()
|
||||
self.register_page.click_third_party_dummy_provider()
|
||||
|
||||
# The user will be redirected somewhere and then back to the register page:
|
||||
msg_text = self.register_page.wait_for_auth_status_message()
|
||||
self.assertEqual(self.register_page.current_form, "register")
|
||||
self.assertIn("You've successfully signed into Dummy", msg_text)
|
||||
self.assertIn("We just need a little more information", msg_text)
|
||||
|
||||
# Now the form should be pre-filled with the data from the Dummy provider:
|
||||
self.assertEqual(self.register_page.email_value, "adama@fleet.colonies.gov")
|
||||
self.assertEqual(self.register_page.full_name_value, "William Adama")
|
||||
self.assertIn("Galactica1", self.register_page.username_value)
|
||||
|
||||
# Set country, accept the terms, and submit the form:
|
||||
self.register_page.register(country="US", terms_of_service=True)
|
||||
|
||||
# Expect that we reach the dashboard and we're auto-enrolled in the course
|
||||
course_names = self.dashboard_page.wait_for_page().available_courses
|
||||
self.assertIn(self.course_info["display_name"], course_names)
|
||||
|
||||
# Now logout and check that we can log back in instantly (because the account is linked):
|
||||
LogoutPage(self.browser).visit()
|
||||
|
||||
login_page = CombinedLoginAndRegisterPage(self.browser, start_page="login")
|
||||
login_page.visit()
|
||||
login_page.click_third_party_dummy_provider()
|
||||
|
||||
self.dashboard_page.wait_for_page()
|
||||
|
||||
# Now unlink the account (To test the account settings view and also to prevent cross-test side effects)
|
||||
account_settings = AccountSettingsPage(self.browser).visit()
|
||||
field_id = "auth-dummy"
|
||||
account_settings.wait_for_field(field_id)
|
||||
self.assertEqual("Unlink", account_settings.link_title_for_link_field(field_id))
|
||||
account_settings.click_on_link_in_link_field(field_id)
|
||||
account_settings.wait_for_message(field_id, "Successfully unlinked")
|
||||
|
||||
|
||||
@attr('shard_4')
|
||||
class PayAndVerifyTest(EventsTestMixin, UniqueCourseTest):
|
||||
|
||||
@@ -2468,59 +2468,6 @@ CREATE TABLE `shoppingcart_registrationcoderedemption` (
|
||||
CONSTRAINT `registration_code_id_refs_id_4d01e47b` FOREIGN KEY (`registration_code_id`) REFERENCES `shoppingcart_courseregistrationcode` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `social_auth_association`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `social_auth_association` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`server_url` varchar(255) NOT NULL,
|
||||
`handle` varchar(255) NOT NULL,
|
||||
`secret` varchar(255) NOT NULL,
|
||||
`issued` int(11) NOT NULL,
|
||||
`lifetime` int(11) NOT NULL,
|
||||
`assoc_type` varchar(64) NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `social_auth_code`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `social_auth_code` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`email` varchar(75) NOT NULL,
|
||||
`code` varchar(32) NOT NULL,
|
||||
`verified` tinyint(1) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `email` (`email`,`code`),
|
||||
KEY `social_auth_code_65da3d2c` (`code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `social_auth_nonce`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `social_auth_nonce` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`server_url` varchar(255) NOT NULL,
|
||||
`timestamp` int(11) NOT NULL,
|
||||
`salt` varchar(65) NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `social_auth_usersocialauth`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `social_auth_usersocialauth` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(11) NOT NULL,
|
||||
`provider` varchar(32) NOT NULL,
|
||||
`uid` varchar(255) NOT NULL,
|
||||
`extra_data` longtext NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `provider` (`provider`,`uid`),
|
||||
KEY `social_auth_usersocialauth_fbfc09f1` (`user_id`),
|
||||
CONSTRAINT `user_id_refs_id_60fa311b` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `south_migrationhistory`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
|
||||
Binary file not shown.
@@ -1,77 +0,0 @@
|
||||
"""Helper functions for the student account app. """
|
||||
from django.core.urlresolvers import reverse
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from course_modes.models import CourseMode
|
||||
from third_party_auth import ( # pylint: disable=W0611
|
||||
pipeline, provider,
|
||||
is_enabled as third_party_auth_enabled
|
||||
)
|
||||
|
||||
|
||||
def auth_pipeline_urls(auth_entry, redirect_url=None, course_id=None, email_opt_in=None):
|
||||
"""Retrieve URLs for each enabled third-party auth provider.
|
||||
|
||||
These URLs are used on the "sign up" and "sign in" buttons
|
||||
on the login/registration forms to allow users to begin
|
||||
authentication with a third-party provider.
|
||||
|
||||
Optionally, we can redirect the user to an arbitrary
|
||||
url after auth completes successfully. We use this
|
||||
to redirect the user to a page that required login,
|
||||
or to send users to the payment flow when enrolling
|
||||
in a course.
|
||||
|
||||
Args:
|
||||
auth_entry (string): Either `pipeline.AUTH_ENTRY_LOGIN` or `pipeline.AUTH_ENTRY_REGISTER`
|
||||
|
||||
Keyword Args:
|
||||
redirect_url (unicode): If provided, send users to this URL
|
||||
after they successfully authenticate.
|
||||
|
||||
course_id (unicode): The ID of the course the user is enrolling in.
|
||||
We use this to send users to the track selection page
|
||||
if the course has a payment option.
|
||||
Note that `redirect_url` takes precedence over the redirect
|
||||
to the track selection page.
|
||||
|
||||
email_opt_in (unicode): The user choice to opt in for organization wide emails. If set to 'true'
|
||||
(case insensitive), user will be opted into organization-wide email. All other values will
|
||||
be treated as False, and the user will be opted out of organization-wide email.
|
||||
|
||||
Returns:
|
||||
dict mapping provider names to URLs
|
||||
|
||||
"""
|
||||
if not third_party_auth_enabled():
|
||||
return {}
|
||||
|
||||
if redirect_url is not None:
|
||||
pipeline_redirect = redirect_url
|
||||
elif course_id is not None:
|
||||
# If the course is white-label (paid), then we send users
|
||||
# to the shopping cart. (There is a third party auth pipeline
|
||||
# step that will add the course to the cart.)
|
||||
if CourseMode.is_white_label(CourseKey.from_string(course_id)):
|
||||
pipeline_redirect = reverse("shoppingcart.views.show_cart")
|
||||
|
||||
# Otherwise, send the user to the track selection page.
|
||||
# The track selection page may redirect the user to the dashboard
|
||||
# (if the only available mode is honor), or directly to verification
|
||||
# (for professional ed).
|
||||
else:
|
||||
pipeline_redirect = reverse(
|
||||
"course_modes_choose",
|
||||
kwargs={'course_id': unicode(course_id)}
|
||||
)
|
||||
else:
|
||||
pipeline_redirect = None
|
||||
|
||||
return {
|
||||
provider.NAME: pipeline.get_login_url(
|
||||
provider.NAME, auth_entry,
|
||||
enroll_course_id=course_id,
|
||||
email_opt_in=email_opt_in,
|
||||
redirect_url=pipeline_redirect
|
||||
)
|
||||
for provider in provider.Registry.enabled()
|
||||
}
|
||||
@@ -245,36 +245,39 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase)
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_login_and_registration_form_signin_preserves_params(self, is_edx_domain, url_name):
|
||||
params = {
|
||||
'enrollment_action': 'enroll',
|
||||
'course_id': 'edX/DemoX/Demo_Course'
|
||||
}
|
||||
params = [
|
||||
('course_id', 'edX/DemoX/Demo_Course'),
|
||||
('enrollment_action', 'enroll'),
|
||||
]
|
||||
|
||||
# The response should have a "Sign In" button with the URL
|
||||
# that preserves the querystring params
|
||||
with mock.patch.dict(settings.FEATURES, {'IS_EDX_DOMAIN': is_edx_domain}):
|
||||
response = self.client.get(reverse(url_name), params)
|
||||
self.assertContains(response, "login?course_id=edX%2FDemoX%2FDemo_Course&enrollment_action=enroll")
|
||||
expected_url = '/login?{}'.format(self._finish_auth_url_param(params + [('next', '/dashboard')]))
|
||||
self.assertContains(response, expected_url)
|
||||
|
||||
# Add an additional "course mode" parameter
|
||||
params['course_mode'] = 'honor'
|
||||
# Add additional parameters:
|
||||
params = [
|
||||
('course_id', 'edX/DemoX/Demo_Course'),
|
||||
('enrollment_action', 'enroll'),
|
||||
('course_mode', 'honor'),
|
||||
('email_opt_in', 'true'),
|
||||
('next', '/custom/final/destination')
|
||||
]
|
||||
|
||||
# Verify that this parameter is also preserved
|
||||
with mock.patch.dict(settings.FEATURES, {'IS_EDX_DOMAIN': is_edx_domain}):
|
||||
response = self.client.get(reverse(url_name), params)
|
||||
|
||||
expected_url = (
|
||||
"login?course_id=edX%2FDemoX%2FDemo_Course"
|
||||
"&enrollment_action=enroll"
|
||||
"&course_mode=honor"
|
||||
)
|
||||
expected_url = '/login?{}'.format(self._finish_auth_url_param(params))
|
||||
self.assertContains(response, expected_url)
|
||||
|
||||
@mock.patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False})
|
||||
@ddt.data("account_login", "account_register")
|
||||
def test_third_party_auth_disabled(self, url_name):
|
||||
response = self.client.get(reverse(url_name))
|
||||
self._assert_third_party_auth_data(response, None, [])
|
||||
self._assert_third_party_auth_data(response, None, None, [])
|
||||
|
||||
@ddt.data(
|
||||
("account_login", None, None),
|
||||
@@ -286,175 +289,40 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase)
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_third_party_auth(self, url_name, current_backend, current_provider):
|
||||
params = [
|
||||
('course_id', 'edX/DemoX/Demo_Course'),
|
||||
('enrollment_action', 'enroll'),
|
||||
('course_mode', 'honor'),
|
||||
('email_opt_in', 'true'),
|
||||
('next', '/custom/final/destination'),
|
||||
]
|
||||
|
||||
# Simulate a running pipeline
|
||||
if current_backend is not None:
|
||||
pipeline_target = "student_account.views.third_party_auth.pipeline"
|
||||
with simulate_running_pipeline(pipeline_target, current_backend):
|
||||
response = self.client.get(reverse(url_name))
|
||||
response = self.client.get(reverse(url_name), params)
|
||||
|
||||
# Do NOT simulate a running pipeline
|
||||
else:
|
||||
response = self.client.get(reverse(url_name))
|
||||
response = self.client.get(reverse(url_name), params)
|
||||
|
||||
# This relies on the THIRD_PARTY_AUTH configuration in the test settings
|
||||
expected_providers = [
|
||||
{
|
||||
"name": "Facebook",
|
||||
"iconClass": "fa-facebook",
|
||||
"loginUrl": self._third_party_login_url("facebook", "login"),
|
||||
"registerUrl": self._third_party_login_url("facebook", "register")
|
||||
"loginUrl": self._third_party_login_url("facebook", "login", params),
|
||||
"registerUrl": self._third_party_login_url("facebook", "register", params)
|
||||
},
|
||||
{
|
||||
"name": "Google",
|
||||
"iconClass": "fa-google-plus",
|
||||
"loginUrl": self._third_party_login_url("google-oauth2", "login"),
|
||||
"registerUrl": self._third_party_login_url("google-oauth2", "register")
|
||||
"loginUrl": self._third_party_login_url("google-oauth2", "login", params),
|
||||
"registerUrl": self._third_party_login_url("google-oauth2", "register", params)
|
||||
}
|
||||
]
|
||||
self._assert_third_party_auth_data(response, current_provider, expected_providers)
|
||||
|
||||
@ddt.data([], ["honor"], ["honor", "verified", "audit"], ["professional"], ["no-id-professional"])
|
||||
def test_third_party_auth_course_id_verified(self, modes):
|
||||
# Create a course with the specified course modes
|
||||
course = CourseFactory.create()
|
||||
for slug in modes:
|
||||
CourseModeFactory.create(
|
||||
course_id=course.id,
|
||||
mode_slug=slug,
|
||||
mode_display_name=slug
|
||||
)
|
||||
|
||||
# Verify that the entry URL for third party auth
|
||||
# contains the course ID and redirects to the track selection page.
|
||||
course_modes_choose_url = reverse(
|
||||
"course_modes_choose",
|
||||
kwargs={"course_id": unicode(course.id)}
|
||||
)
|
||||
expected_providers = [
|
||||
{
|
||||
"name": "Facebook",
|
||||
"iconClass": "fa-facebook",
|
||||
"loginUrl": self._third_party_login_url(
|
||||
"facebook", "login",
|
||||
course_id=unicode(course.id),
|
||||
redirect_url=course_modes_choose_url
|
||||
),
|
||||
"registerUrl": self._third_party_login_url(
|
||||
"facebook", "register",
|
||||
course_id=unicode(course.id),
|
||||
redirect_url=course_modes_choose_url
|
||||
)
|
||||
},
|
||||
{
|
||||
"name": "Google",
|
||||
"iconClass": "fa-google-plus",
|
||||
"loginUrl": self._third_party_login_url(
|
||||
"google-oauth2", "login",
|
||||
course_id=unicode(course.id),
|
||||
redirect_url=course_modes_choose_url
|
||||
),
|
||||
"registerUrl": self._third_party_login_url(
|
||||
"google-oauth2", "register",
|
||||
course_id=unicode(course.id),
|
||||
redirect_url=course_modes_choose_url
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
# Verify that the login page contains the correct provider URLs
|
||||
response = self.client.get(reverse("account_login"), {"course_id": unicode(course.id)})
|
||||
self._assert_third_party_auth_data(response, None, expected_providers)
|
||||
|
||||
def test_third_party_auth_course_id_shopping_cart(self):
|
||||
# Create a course with a white-label course mode
|
||||
course = CourseFactory.create()
|
||||
CourseModeFactory.create(
|
||||
course_id=course.id,
|
||||
mode_slug="honor",
|
||||
mode_display_name="Honor",
|
||||
min_price=100
|
||||
)
|
||||
|
||||
# Verify that the entry URL for third party auth
|
||||
# contains the course ID and redirects to the shopping cart
|
||||
shoppingcart_url = reverse("shoppingcart.views.show_cart")
|
||||
expected_providers = [
|
||||
{
|
||||
"name": "Facebook",
|
||||
"iconClass": "fa-facebook",
|
||||
"loginUrl": self._third_party_login_url(
|
||||
"facebook", "login",
|
||||
course_id=unicode(course.id),
|
||||
redirect_url=shoppingcart_url
|
||||
),
|
||||
"registerUrl": self._third_party_login_url(
|
||||
"facebook", "register",
|
||||
course_id=unicode(course.id),
|
||||
redirect_url=shoppingcart_url
|
||||
)
|
||||
},
|
||||
{
|
||||
"name": "Google",
|
||||
"iconClass": "fa-google-plus",
|
||||
"loginUrl": self._third_party_login_url(
|
||||
"google-oauth2", "login",
|
||||
course_id=unicode(course.id),
|
||||
redirect_url=shoppingcart_url
|
||||
),
|
||||
"registerUrl": self._third_party_login_url(
|
||||
"google-oauth2", "register",
|
||||
course_id=unicode(course.id),
|
||||
redirect_url=shoppingcart_url
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
# Verify that the login page contains the correct provider URLs
|
||||
response = self.client.get(reverse("account_login"), {"course_id": unicode(course.id)})
|
||||
self._assert_third_party_auth_data(response, None, expected_providers)
|
||||
|
||||
@mock.patch.dict(settings.FEATURES, {'EMBARGO': True})
|
||||
def test_third_party_auth_enrollment_embargo(self):
|
||||
course = CourseFactory.create()
|
||||
|
||||
# Start the pipeline attempting to enroll in a restricted course
|
||||
with restrict_course(course.id) as redirect_url:
|
||||
response = self.client.get(reverse("account_login"), {"course_id": unicode(course.id)})
|
||||
|
||||
# Expect that the course ID has been removed from the
|
||||
# login URLs (so the user won't be enrolled) and
|
||||
# the ?next param sends users to the blocked message.
|
||||
expected_providers = [
|
||||
{
|
||||
"name": "Facebook",
|
||||
"iconClass": "fa-facebook",
|
||||
"loginUrl": self._third_party_login_url(
|
||||
"facebook", "login",
|
||||
course_id=unicode(course.id),
|
||||
redirect_url=redirect_url
|
||||
),
|
||||
"registerUrl": self._third_party_login_url(
|
||||
"facebook", "register",
|
||||
course_id=unicode(course.id),
|
||||
redirect_url=redirect_url
|
||||
)
|
||||
},
|
||||
{
|
||||
"name": "Google",
|
||||
"iconClass": "fa-google-plus",
|
||||
"loginUrl": self._third_party_login_url(
|
||||
"google-oauth2", "login",
|
||||
course_id=unicode(course.id),
|
||||
redirect_url=redirect_url
|
||||
),
|
||||
"registerUrl": self._third_party_login_url(
|
||||
"google-oauth2", "register",
|
||||
course_id=unicode(course.id),
|
||||
redirect_url=redirect_url
|
||||
)
|
||||
}
|
||||
]
|
||||
self._assert_third_party_auth_data(response, None, expected_providers)
|
||||
self._assert_third_party_auth_data(response, current_backend, current_provider, expected_providers)
|
||||
|
||||
@override_settings(SITE_NAME=settings.MICROSITE_TEST_HOSTNAME)
|
||||
def test_microsite_uses_old_login_page(self):
|
||||
@@ -477,33 +345,42 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase)
|
||||
self.assertContains(resp, "Register for Test Microsite")
|
||||
self.assertContains(resp, "register-form")
|
||||
|
||||
def _assert_third_party_auth_data(self, response, current_provider, providers):
|
||||
def _assert_third_party_auth_data(self, response, current_backend, current_provider, providers):
|
||||
"""Verify that third party auth info is rendered correctly in a DOM data attribute. """
|
||||
auth_info = markupsafe.escape(
|
||||
json.dumps({
|
||||
"currentProvider": current_provider,
|
||||
"providers": providers
|
||||
"providers": providers,
|
||||
"finishAuthUrl": "/auth/complete/{}?".format(current_backend) if current_backend else None,
|
||||
"errorMessage": None,
|
||||
})
|
||||
)
|
||||
|
||||
expected_data = u"data-third-party-auth='{auth_info}'".format(
|
||||
auth_info=auth_info
|
||||
)
|
||||
|
||||
self.assertContains(response, expected_data)
|
||||
|
||||
def _third_party_login_url(self, backend_name, auth_entry, course_id=None, redirect_url=None):
|
||||
def _third_party_login_url(self, backend_name, auth_entry, login_params):
|
||||
"""Construct the login URL to start third party authentication. """
|
||||
params = [("auth_entry", auth_entry)]
|
||||
if redirect_url:
|
||||
params.append(("next", redirect_url))
|
||||
if course_id:
|
||||
params.append(("enroll_course_id", course_id))
|
||||
|
||||
return u"{url}?{params}".format(
|
||||
return u"{url}?auth_entry={auth_entry}&{param_str}".format(
|
||||
url=reverse("social:begin", kwargs={"backend": backend_name}),
|
||||
params=urlencode(params)
|
||||
auth_entry=auth_entry,
|
||||
param_str=self._finish_auth_url_param(login_params),
|
||||
)
|
||||
|
||||
def _finish_auth_url_param(self, params):
|
||||
"""
|
||||
Make the next=... URL parameter that indicates where the user should go next.
|
||||
|
||||
>>> _finish_auth_url_param([('next', '/dashboard')])
|
||||
'/account/finish_auth?next=%2Fdashboard'
|
||||
"""
|
||||
return urlencode({
|
||||
'next': '/account/finish_auth?{}'.format(urlencode(params))
|
||||
})
|
||||
|
||||
|
||||
class AccountSettingsViewTest(TestCase):
|
||||
""" Tests for the account settings view. """
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from django.conf.urls import patterns, url
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
urlpatterns = []
|
||||
|
||||
if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'):
|
||||
@@ -14,5 +13,6 @@ if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'):
|
||||
|
||||
urlpatterns += patterns(
|
||||
'student_account.views',
|
||||
url(r'^finish_auth$', 'finish_auth', name='finish_auth'),
|
||||
url(r'^settings$', 'account_settings', name='account_settings'),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import logging
|
||||
import json
|
||||
from ipware.ip import get_ip
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
@@ -19,12 +18,9 @@ from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from lang_pref.api import released_languages
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from microsite_configuration import microsite
|
||||
|
||||
from embargo import api as embargo_api
|
||||
from external_auth.login_and_register import (
|
||||
login as external_auth_login,
|
||||
register as external_auth_register
|
||||
@@ -34,16 +30,13 @@ from student.views import (
|
||||
signin_user as old_login_view,
|
||||
register_user as old_register_view
|
||||
)
|
||||
from student_account.helpers import auth_pipeline_urls
|
||||
from student.helpers import get_next_url_for_login_page
|
||||
import third_party_auth
|
||||
from third_party_auth import pipeline
|
||||
from util.bad_request_rate_limiter import BadRequestRateLimiter
|
||||
|
||||
from openedx.core.djangoapps.user_api.accounts.api import request_password_change
|
||||
from openedx.core.djangoapps.user_api.errors import UserNotFound
|
||||
from util.bad_request_rate_limiter import BadRequestRateLimiter
|
||||
|
||||
from student_account.helpers import auth_pipeline_urls
|
||||
|
||||
|
||||
AUDIT_LOG = logging.getLogger("audit")
|
||||
@@ -61,9 +54,12 @@ def login_and_registration_form(request, initial_mode="login"):
|
||||
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(reverse('dashboard'))
|
||||
return redirect(redirect_to)
|
||||
|
||||
# Retrieve the form descriptions from the user API
|
||||
form_descriptions = _get_form_descriptions(request)
|
||||
@@ -83,9 +79,10 @@ def login_and_registration_form(request, initial_mode="login"):
|
||||
|
||||
# Otherwise, render the combined login/registration page
|
||||
context = {
|
||||
'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header
|
||||
'disable_courseware_js': True,
|
||||
'initial_mode': initial_mode,
|
||||
'third_party_auth': json.dumps(_third_party_auth_context(request)),
|
||||
'third_party_auth': json.dumps(_third_party_auth_context(request, redirect_to)),
|
||||
'platform_name': settings.PLATFORM_NAME,
|
||||
'responsive': True,
|
||||
|
||||
@@ -96,12 +93,6 @@ def login_and_registration_form(request, initial_mode="login"):
|
||||
'login_form_desc': form_descriptions['login'],
|
||||
'registration_form_desc': form_descriptions['registration'],
|
||||
'password_reset_form_desc': form_descriptions['password_reset'],
|
||||
|
||||
# We need to pass these parameters so that the header's
|
||||
# "Sign In" button preserves the querystring params.
|
||||
'enrollment_action': request.GET.get('enrollment_action'),
|
||||
'course_id': request.GET.get('course_id'),
|
||||
'course_mode': request.GET.get('course_mode'),
|
||||
}
|
||||
|
||||
return render_to_response('student_account/login_and_register.html', context)
|
||||
@@ -157,12 +148,14 @@ def password_change_request_handler(request):
|
||||
return HttpResponseBadRequest(_("No email address provided."))
|
||||
|
||||
|
||||
def _third_party_auth_context(request):
|
||||
def _third_party_auth_context(request, redirect_to):
|
||||
"""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.
|
||||
|
||||
Returns:
|
||||
dict
|
||||
@@ -170,72 +163,43 @@ def _third_party_auth_context(request):
|
||||
"""
|
||||
context = {
|
||||
"currentProvider": None,
|
||||
"providers": []
|
||||
"providers": [],
|
||||
"finishAuthUrl": None,
|
||||
"errorMessage": None,
|
||||
}
|
||||
|
||||
course_id = request.GET.get("course_id")
|
||||
email_opt_in = request.GET.get('email_opt_in')
|
||||
redirect_to = request.GET.get("next")
|
||||
|
||||
# Check if the user is trying to enroll in a course
|
||||
# that they don't have access to based on country
|
||||
# access rules.
|
||||
#
|
||||
# If so, set the redirect URL to the blocked page.
|
||||
# We need to set it here, rather than redirecting
|
||||
# from within the pipeline, because a redirect
|
||||
# from the pipeline can prevent users
|
||||
# from completing the authentication process.
|
||||
#
|
||||
# Note that we can't check the user's country
|
||||
# profile at this point, since the user hasn't
|
||||
# authenticated. If the user ends up being blocked
|
||||
# by their country preference, we let them enroll;
|
||||
# they'll still be blocked when they try to access
|
||||
# the courseware.
|
||||
if course_id:
|
||||
try:
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
redirect_url = embargo_api.redirect_if_blocked(
|
||||
course_key,
|
||||
ip_address=get_ip(request),
|
||||
url=request.path
|
||||
)
|
||||
if redirect_url:
|
||||
redirect_to = embargo_api.message_url_path(course_key, "enrollment")
|
||||
except InvalidKeyError:
|
||||
pass
|
||||
|
||||
login_urls = auth_pipeline_urls(
|
||||
third_party_auth.pipeline.AUTH_ENTRY_LOGIN,
|
||||
course_id=course_id,
|
||||
email_opt_in=email_opt_in,
|
||||
redirect_url=redirect_to
|
||||
)
|
||||
register_urls = auth_pipeline_urls(
|
||||
third_party_auth.pipeline.AUTH_ENTRY_REGISTER,
|
||||
course_id=course_id,
|
||||
email_opt_in=email_opt_in,
|
||||
redirect_url=redirect_to
|
||||
)
|
||||
|
||||
if third_party_auth.is_enabled():
|
||||
context["providers"] = [
|
||||
{
|
||||
"name": enabled.NAME,
|
||||
"iconClass": enabled.ICON_CLASS,
|
||||
"loginUrl": login_urls[enabled.NAME],
|
||||
"registerUrl": register_urls[enabled.NAME]
|
||||
"loginUrl": pipeline.get_login_url(
|
||||
enabled.NAME,
|
||||
pipeline.AUTH_ENTRY_LOGIN,
|
||||
redirect_url=redirect_to,
|
||||
),
|
||||
"registerUrl": pipeline.get_login_url(
|
||||
enabled.NAME,
|
||||
pipeline.AUTH_ENTRY_REGISTER,
|
||||
redirect_url=redirect_to,
|
||||
),
|
||||
}
|
||||
for enabled in third_party_auth.provider.Registry.enabled()
|
||||
]
|
||||
|
||||
running_pipeline = third_party_auth.pipeline.get(request)
|
||||
running_pipeline = pipeline.get(request)
|
||||
if running_pipeline is not None:
|
||||
current_provider = third_party_auth.provider.Registry.get_by_backend_name(
|
||||
running_pipeline.get('backend')
|
||||
)
|
||||
context["currentProvider"] = current_provider.NAME
|
||||
context["finishAuthUrl"] = pipeline.get_complete_url(current_provider.BACKEND_CLASS.name)
|
||||
|
||||
# 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":
|
||||
context['errorMessage'] = unicode(msg)
|
||||
break
|
||||
|
||||
return context
|
||||
|
||||
@@ -326,6 +290,39 @@ def account_settings(request):
|
||||
return render_to_response('student_account/account_settings.html', account_settings_context(request))
|
||||
|
||||
|
||||
@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,
|
||||
})
|
||||
|
||||
|
||||
def account_settings_context(request):
|
||||
""" Context for the account settings page.
|
||||
|
||||
|
||||
@@ -532,6 +532,9 @@ X_FRAME_OPTIONS = ENV_TOKENS.get('X_FRAME_OPTIONS', X_FRAME_OPTIONS)
|
||||
##### Third-party auth options ################################################
|
||||
THIRD_PARTY_AUTH = AUTH_TOKENS.get('THIRD_PARTY_AUTH', THIRD_PARTY_AUTH)
|
||||
|
||||
# The reduced session expiry time during the third party login pipeline. (Value in seconds)
|
||||
SOCIAL_AUTH_PIPELINE_TIMEOUT = ENV_TOKENS.get('SOCIAL_AUTH_PIPELINE_TIMEOUT', 600)
|
||||
|
||||
##### OAUTH2 Provider ##############
|
||||
if FEATURES.get('ENABLE_OAUTH2_PROVIDER'):
|
||||
OAUTH_OIDC_ISSUER = ENV_TOKENS['OAUTH_OIDC_ISSUER']
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
},
|
||||
"SECRET_KEY": "",
|
||||
"THIRD_PARTY_AUTH": {
|
||||
"Dummy": {},
|
||||
"Google": {
|
||||
"SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": "test",
|
||||
"SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test"
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"ENABLE_INSTRUCTOR_ANALYTICS": true,
|
||||
"ENABLE_S3_GRADE_DOWNLOADS": true,
|
||||
"ENABLE_THIRD_PARTY_AUTH": true,
|
||||
"ENABLE_DUMMY_THIRD_PARTY_AUTH_PROVIDER": true,
|
||||
"ENABLE_COMBINED_LOGIN_REGISTRATION": true,
|
||||
"PREVIEW_LMS_BASE": "localhost:8003",
|
||||
"SUBDOMAIN_BRANDING": false,
|
||||
|
||||
@@ -1556,6 +1556,10 @@ PIPELINE_JS = {
|
||||
'certificates_wv': {
|
||||
'source_filenames': certificates_web_view_js,
|
||||
'output_filename': 'js/certificates/web_view.js'
|
||||
},
|
||||
'utility': {
|
||||
'source_filenames': ['js/src/utility.js'],
|
||||
'output_filename': 'js/utility.js'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -242,11 +242,13 @@ THIRD_PARTY_AUTH = {
|
||||
"SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test",
|
||||
},
|
||||
"Facebook": {
|
||||
"SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": "test",
|
||||
"SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test",
|
||||
"SOCIAL_AUTH_FACEBOOK_KEY": "test",
|
||||
"SOCIAL_AUTH_FACEBOOK_SECRET": "test",
|
||||
},
|
||||
}
|
||||
|
||||
FEATURES['ENABLE_DUMMY_THIRD_PARTY_AUTH_PROVIDER'] = True
|
||||
|
||||
################################## OPENID #####################################
|
||||
FEATURES['AUTH_USE_OPENID'] = True
|
||||
FEATURES['AUTH_USE_OPENID_PROVIDER'] = True
|
||||
|
||||
@@ -449,7 +449,6 @@
|
||||
'jquery',
|
||||
'underscore',
|
||||
'backbone',
|
||||
'gettext',
|
||||
'history',
|
||||
'utility',
|
||||
'js/student_account/views/LoginView',
|
||||
@@ -458,10 +457,7 @@
|
||||
'js/student_account/models/LoginModel',
|
||||
'js/student_account/models/PasswordResetModel',
|
||||
'js/student_account/models/RegisterModel',
|
||||
'js/student_account/views/FormView',
|
||||
'js/student_account/emailoptin',
|
||||
'js/student_account/enrollment',
|
||||
'js/student_account/shoppingcart',
|
||||
'js/student_account/views/FormView'
|
||||
]
|
||||
},
|
||||
'js/verify_student/models/verification_model': {
|
||||
@@ -598,6 +594,7 @@
|
||||
'lms/include/js/spec/instructor_dashboard/student_admin_spec.js',
|
||||
'lms/include/js/spec/student_account/account_spec.js',
|
||||
'lms/include/js/spec/student_account/access_spec.js',
|
||||
'lms/include/js/spec/student_account/finish_auth_spec.js',
|
||||
'lms/include/js/spec/student_account/login_spec.js',
|
||||
'lms/include/js/spec/student_account/register_spec.js',
|
||||
'lms/include/js/spec/student_account/password_reset_spec.js',
|
||||
|
||||
@@ -8,9 +8,8 @@ define([
|
||||
'js/student_account/shoppingcart',
|
||||
'js/student_account/emailoptin'
|
||||
], function($, TemplateHelpers, AjaxHelpers, AccessView, FormView, EnrollmentInterface, ShoppingCartInterface) {
|
||||
"use strict";
|
||||
describe('edx.student.account.AccessView', function() {
|
||||
'use strict';
|
||||
|
||||
var requests = null,
|
||||
view = null,
|
||||
FORM_DESCRIPTION = {
|
||||
@@ -41,10 +40,15 @@ define([
|
||||
}
|
||||
]
|
||||
},
|
||||
FORWARD_URL = '/courseware/next',
|
||||
COURSE_KEY = 'edx/DemoX/Fall';
|
||||
FORWARD_URL = (
|
||||
'/account/finish_auth' +
|
||||
'?course_id=edx%2FDemoX%2FFall' +
|
||||
'&enrollment_action=enroll' +
|
||||
'&next=%2Fdashboard'
|
||||
),
|
||||
THIRD_PARTY_COMPLETE_URL = '/auth/complete/provider/';
|
||||
|
||||
var ajaxSpyAndInitialize = function(that, mode) {
|
||||
var ajaxSpyAndInitialize = function(that, mode, nextUrl, finishAuthUrl) {
|
||||
// Spy on AJAX requests
|
||||
requests = AjaxHelpers.requests(that);
|
||||
|
||||
@@ -53,8 +57,10 @@ define([
|
||||
mode: mode,
|
||||
thirdPartyAuth: {
|
||||
currentProvider: null,
|
||||
providers: []
|
||||
providers: [],
|
||||
finishAuthUrl: finishAuthUrl
|
||||
},
|
||||
nextUrl: nextUrl, // undefined for default
|
||||
platformName: 'edX',
|
||||
loginFormDesc: FORM_DESCRIPTION,
|
||||
registrationFormDesc: FORM_DESCRIPTION,
|
||||
@@ -84,20 +90,6 @@ define([
|
||||
view.toggleForm(changeEvent);
|
||||
};
|
||||
|
||||
/**
|
||||
* Simulate query string params.
|
||||
*
|
||||
* @param {object} params Parameters to set, each of which
|
||||
* should be prefixed with '?'
|
||||
*/
|
||||
var setFakeQueryParams = function( params ) {
|
||||
spyOn( $, 'url' ).andCallFake(function( requestedParam ) {
|
||||
if ( params.hasOwnProperty(requestedParam) ) {
|
||||
return params[requestedParam];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(function() {
|
||||
setFixtures('<div id="login-and-registration-container"></div>');
|
||||
TemplateHelpers.installTemplate('templates/student_account/access');
|
||||
@@ -156,83 +148,6 @@ define([
|
||||
expect($("#password-reset-form")).not.toHaveClass('hidden');
|
||||
});
|
||||
|
||||
it('enrolls the user on auth complete', function() {
|
||||
ajaxSpyAndInitialize(this, 'login');
|
||||
|
||||
// Simulate providing enrollment query string params
|
||||
setFakeQueryParams({
|
||||
'?enrollment_action': 'enroll',
|
||||
'?course_id': COURSE_KEY
|
||||
});
|
||||
|
||||
// Trigger auth complete on the login view
|
||||
view.subview.login.trigger('auth-complete');
|
||||
|
||||
// Expect that the view tried to enroll the student
|
||||
expect( EnrollmentInterface.enroll ).toHaveBeenCalledWith(
|
||||
COURSE_KEY,
|
||||
'/course_modes/choose/' + COURSE_KEY + '/'
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the user to the payment flow when the course mode is not honor', function() {
|
||||
ajaxSpyAndInitialize(this, 'login');
|
||||
|
||||
// Simulate providing enrollment query string params
|
||||
// AND specifying a course mode.
|
||||
setFakeQueryParams({
|
||||
'?enrollment_action': 'enroll',
|
||||
'?course_id': COURSE_KEY,
|
||||
'?course_mode': 'verified'
|
||||
});
|
||||
|
||||
// Trigger auth complete on the login view
|
||||
view.subview.login.trigger('auth-complete');
|
||||
|
||||
// Expect that the view tried to auto-enroll the student
|
||||
// with a redirect into the payment flow.
|
||||
expect( EnrollmentInterface.enroll ).toHaveBeenCalledWith(
|
||||
COURSE_KEY,
|
||||
'/verify_student/start-flow/' + COURSE_KEY + '/'
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the user to the student dashboard when the course mode is honor', function() {
|
||||
ajaxSpyAndInitialize(this, 'login');
|
||||
|
||||
// Simulate providing enrollment query string params
|
||||
// AND specifying a course mode.
|
||||
setFakeQueryParams({
|
||||
'?enrollment_action': 'enroll',
|
||||
'?course_id': COURSE_KEY,
|
||||
'?course_mode': 'honor'
|
||||
});
|
||||
|
||||
// Trigger auth complete on the login view
|
||||
view.subview.login.trigger('auth-complete');
|
||||
|
||||
// Expect that the view tried auto-enrolled the student
|
||||
// and sent the student to the dashboard
|
||||
// (skipping the payment flow).
|
||||
expect( EnrollmentInterface.enroll ).toHaveBeenCalledWith(COURSE_KEY, '/dashboard');
|
||||
});
|
||||
|
||||
it('adds a white-label course to the shopping cart on auth complete', function() {
|
||||
ajaxSpyAndInitialize(this, 'register');
|
||||
|
||||
// Simulate providing "add to cart" query string params
|
||||
setFakeQueryParams({
|
||||
'?enrollment_action': 'add_to_cart',
|
||||
'?course_id': COURSE_KEY
|
||||
});
|
||||
|
||||
// Trigger auth complete on the register view
|
||||
view.subview.register.trigger('auth-complete');
|
||||
|
||||
// Expect that the view tried to add the course to the user's shopping cart
|
||||
expect( ShoppingCartInterface.addCourseToCart ).toHaveBeenCalledWith( COURSE_KEY );
|
||||
});
|
||||
|
||||
it('redirects the user to the dashboard on auth complete', function() {
|
||||
ajaxSpyAndInitialize(this, 'register');
|
||||
|
||||
@@ -243,11 +158,19 @@ define([
|
||||
expect( view.redirect ).toHaveBeenCalledWith( '/dashboard' );
|
||||
});
|
||||
|
||||
it('redirects the user to the next page on auth complete', function() {
|
||||
ajaxSpyAndInitialize(this, 'register');
|
||||
it('proceeds with the third party auth pipeline if active', function() {
|
||||
ajaxSpyAndInitialize(this, 'register', '/', THIRD_PARTY_COMPLETE_URL);
|
||||
|
||||
// Simulate providing a ?next query string parameter
|
||||
setFakeQueryParams({ '?next': FORWARD_URL });
|
||||
// Trigger auth complete
|
||||
view.subview.register.trigger('auth-complete');
|
||||
|
||||
// Verify that we were redirected
|
||||
expect( view.redirect ).toHaveBeenCalledWith( THIRD_PARTY_COMPLETE_URL );
|
||||
});
|
||||
|
||||
it('redirects the user to the next page on auth complete', function() {
|
||||
// The 'next' argument is often used to redirect to the auto-enrollment view
|
||||
ajaxSpyAndInitialize(this, 'register', FORWARD_URL);
|
||||
|
||||
// Trigger auth complete
|
||||
view.subview.register.trigger('auth-complete');
|
||||
@@ -257,11 +180,7 @@ define([
|
||||
});
|
||||
|
||||
it('ignores redirect to external URLs', function() {
|
||||
ajaxSpyAndInitialize(this, 'register');
|
||||
|
||||
// Simulate providing a ?next query string parameter
|
||||
// that goes to an external URL
|
||||
setFakeQueryParams({ '?next': "http://www.example.com" });
|
||||
ajaxSpyAndInitialize(this, 'register', "http://www.example.com");
|
||||
|
||||
// Trigger auth complete
|
||||
view.subview.register.trigger('auth-complete');
|
||||
|
||||
171
lms/static/js/spec/student_account/finish_auth_spec.js
Normal file
171
lms/static/js/spec/student_account/finish_auth_spec.js
Normal file
@@ -0,0 +1,171 @@
|
||||
define([
|
||||
'jquery',
|
||||
'utility',
|
||||
'common/js/spec_helpers/ajax_helpers',
|
||||
'js/student_account/views/FinishAuthView',
|
||||
'js/student_account/enrollment',
|
||||
'js/student_account/shoppingcart',
|
||||
'js/student_account/emailoptin'
|
||||
], function($, utility, AjaxHelpers, FinishAuthView, EnrollmentInterface, ShoppingCartInterface, EmailOptInInterface) {
|
||||
'use strict';
|
||||
describe('FinishAuthView', function() {
|
||||
var requests = null,
|
||||
view = null,
|
||||
FORWARD_URL = '/courseware/next',
|
||||
COURSE_KEY = 'course-v1:edX+test+15';
|
||||
|
||||
var ajaxSpyAndInitialize = function(that) {
|
||||
// Spy on AJAX requests
|
||||
requests = AjaxHelpers.requests(that);
|
||||
|
||||
// Initialize the access view
|
||||
view = new FinishAuthView({});
|
||||
|
||||
// Mock the redirect call
|
||||
spyOn( view, 'redirect' ).andCallFake( function() {} );
|
||||
|
||||
// Mock the enrollment and shopping cart interfaces
|
||||
spyOn( EnrollmentInterface, 'enroll' ).andCallFake( function() {} );
|
||||
spyOn( ShoppingCartInterface, 'addCourseToCart' ).andCallFake( function() {} );
|
||||
spyOn( EmailOptInInterface, 'setPreference' )
|
||||
.andCallFake( function() { return {'always': function(r) { r(); }}; } );
|
||||
|
||||
view.render();
|
||||
};
|
||||
|
||||
/**
|
||||
* Simulate query string params.
|
||||
*
|
||||
* @param {object} params Parameters to set, each of which
|
||||
* should be prefixed with '?'
|
||||
*/
|
||||
var setFakeQueryParams = function( params ) {
|
||||
spyOn( $, 'url' ).andCallFake(function( requestedParam ) {
|
||||
if ( params.hasOwnProperty(requestedParam) ) {
|
||||
return params[requestedParam];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(function() {
|
||||
// Stub analytics tracking
|
||||
window.analytics = jasmine.createSpyObj('analytics', ['track', 'page', 'pageview', 'trackLink']);
|
||||
});
|
||||
|
||||
it('saves the email opt-in preference before enrollment', function() {
|
||||
// Simulate providing enrollment query string params
|
||||
setFakeQueryParams({
|
||||
'?enrollment_action': 'enroll',
|
||||
'?course_id': COURSE_KEY,
|
||||
'?email_opt_in': 'true'
|
||||
});
|
||||
|
||||
ajaxSpyAndInitialize(this);
|
||||
|
||||
// Expect that the view tried to save the email opt in preference
|
||||
expect( EmailOptInInterface.setPreference ).toHaveBeenCalledWith(
|
||||
COURSE_KEY,
|
||||
'true'
|
||||
);
|
||||
// Expect that the view tried to enroll the student
|
||||
expect( EnrollmentInterface.enroll ).toHaveBeenCalledWith(
|
||||
COURSE_KEY,
|
||||
'/course_modes/choose/' + COURSE_KEY + '/'
|
||||
);
|
||||
});
|
||||
|
||||
it('enrolls the user on auth complete', function() {
|
||||
// Simulate providing enrollment query string params
|
||||
setFakeQueryParams({
|
||||
'?enrollment_action': 'enroll',
|
||||
'?course_id': COURSE_KEY
|
||||
});
|
||||
|
||||
ajaxSpyAndInitialize(this);
|
||||
|
||||
// Expect that the view tried to enroll the student
|
||||
expect( EnrollmentInterface.enroll ).toHaveBeenCalledWith(
|
||||
COURSE_KEY,
|
||||
'/course_modes/choose/' + COURSE_KEY + '/'
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the user to the payment flow when the course mode is not honor', function() {
|
||||
// Simulate providing enrollment query string params
|
||||
// AND specifying a course mode.
|
||||
setFakeQueryParams({
|
||||
'?enrollment_action': 'enroll',
|
||||
'?course_id': COURSE_KEY,
|
||||
'?course_mode': 'verified'
|
||||
});
|
||||
|
||||
ajaxSpyAndInitialize(this);
|
||||
|
||||
// Expect that the view tried to auto-enroll the student
|
||||
// with a redirect into the payment flow.
|
||||
expect( EnrollmentInterface.enroll ).toHaveBeenCalledWith(
|
||||
COURSE_KEY,
|
||||
'/verify_student/start-flow/' + COURSE_KEY + '/'
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the user to the student dashboard when the course mode is honor', function() {
|
||||
// Simulate providing enrollment query string params
|
||||
// AND specifying a course mode.
|
||||
setFakeQueryParams({
|
||||
'?enrollment_action': 'enroll',
|
||||
'?course_id': COURSE_KEY,
|
||||
'?course_mode': 'honor'
|
||||
});
|
||||
|
||||
ajaxSpyAndInitialize(this);
|
||||
|
||||
// Expect that the view tried auto-enrolled the student
|
||||
// and sent the student to the dashboard
|
||||
// (skipping the payment flow).
|
||||
expect( EnrollmentInterface.enroll ).toHaveBeenCalledWith(COURSE_KEY, '/dashboard');
|
||||
});
|
||||
|
||||
it('adds a white-label course to the shopping cart on auth complete', function() {
|
||||
// Simulate providing "add to cart" query string params
|
||||
setFakeQueryParams({
|
||||
'?enrollment_action': 'add_to_cart',
|
||||
'?course_id': COURSE_KEY
|
||||
});
|
||||
|
||||
ajaxSpyAndInitialize(this);
|
||||
|
||||
// Expect that the view tried to add the course to the user's shopping cart
|
||||
expect( ShoppingCartInterface.addCourseToCart ).toHaveBeenCalledWith( COURSE_KEY );
|
||||
});
|
||||
|
||||
it('redirects the user to the dashboard if no course is provided', function() {
|
||||
ajaxSpyAndInitialize(this);
|
||||
|
||||
// Since we did not provide a ?next query param, expect a redirect to the dashboard.
|
||||
expect( view.redirect ).toHaveBeenCalledWith( '/dashboard' );
|
||||
});
|
||||
|
||||
it('redirects the user to the next page when done', function() {
|
||||
// Simulate providing a ?next query string parameter
|
||||
setFakeQueryParams({ '?next': FORWARD_URL });
|
||||
|
||||
ajaxSpyAndInitialize(this);
|
||||
|
||||
// Verify that we were redirected
|
||||
expect( view.redirect ).toHaveBeenCalledWith( FORWARD_URL );
|
||||
});
|
||||
|
||||
it('ignores redirect to external URLs', function() {
|
||||
// Simulate providing a ?next query string parameter
|
||||
// that goes to an external URL
|
||||
setFakeQueryParams({ '?next': "http://www.example.com" });
|
||||
|
||||
ajaxSpyAndInitialize(this);
|
||||
|
||||
// Expect that we ignore the external URL and redirect to the dashboard
|
||||
expect( view.redirect ).toHaveBeenCalledWith( "/dashboard" );
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -11,6 +11,7 @@ var edx = edx || {};
|
||||
return new edx.student.account.AccessView({
|
||||
mode: container.data('initial-mode'),
|
||||
thirdPartyAuth: container.data('third-party-auth'),
|
||||
nextUrl: container.data('next-url'),
|
||||
platformName: container.data('platform-name'),
|
||||
loginFormDesc: container.data('login-form-desc'),
|
||||
registrationFormDesc: container.data('registration-form-desc'),
|
||||
|
||||
@@ -22,13 +22,12 @@ var edx = edx || {};
|
||||
* @param {string} courseKey Slash-separated course key.
|
||||
* @param {string} emailOptIn The preference to opt in or out of organization emails.
|
||||
*/
|
||||
setPreference: function( courseKey, emailOptIn, context ) {
|
||||
setPreference: function( courseKey, emailOptIn ) {
|
||||
return $.ajax({
|
||||
url: this.urls.emailOptInUrl,
|
||||
type: 'POST',
|
||||
data: {course_id: courseKey, email_opt_in: emailOptIn},
|
||||
headers: this.headers,
|
||||
context: context
|
||||
headers: this.headers
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
var edx = edx || {};
|
||||
|
||||
(function($, _, _s, Backbone, gettext) {
|
||||
(function($, _, _s, Backbone, History) {
|
||||
'use strict';
|
||||
|
||||
edx.student = edx.student || {};
|
||||
edx.student.account = edx.student.account || {};
|
||||
|
||||
// Bind to StateChange Event
|
||||
History.Adapter.bind( window, 'statechange', function() {
|
||||
/* Note: We are using History.getState() for legacy browser (IE) support
|
||||
* using History.js plugin instead of the native event.state
|
||||
*/
|
||||
var State = History.getState();
|
||||
});
|
||||
|
||||
edx.student.account.AccessView = Backbone.View.extend({
|
||||
el: '#login-and-registration-container',
|
||||
|
||||
@@ -29,11 +21,7 @@ var edx = edx || {};
|
||||
passwordHelp: {}
|
||||
},
|
||||
|
||||
urls: {
|
||||
dashboard: '/dashboard',
|
||||
payment: '/verify_student/start-flow/',
|
||||
trackSelection: '/course_modes/choose/'
|
||||
},
|
||||
nextUrl: '/dashboard',
|
||||
|
||||
// The form currently loaded
|
||||
activeForm: '',
|
||||
@@ -54,6 +42,13 @@ var edx = edx || {};
|
||||
providers: []
|
||||
};
|
||||
|
||||
if (obj.nextUrl) {
|
||||
// Ensure that the next URL is internal for security reasons
|
||||
if ( ! window.isExternal( obj.nextUrl ) ) {
|
||||
this.nextUrl = obj.nextUrl;
|
||||
}
|
||||
}
|
||||
|
||||
this.formDescriptions = {
|
||||
login: obj.loginFormDesc,
|
||||
register: obj.registrationFormDesc,
|
||||
@@ -69,6 +64,10 @@ var edx = edx || {};
|
||||
});
|
||||
|
||||
this.render();
|
||||
|
||||
// Once the third party error message has been shown once,
|
||||
// there is no need to show it again, if the user changes mode:
|
||||
this.thirdPartyAuth.errorMessage = null;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
@@ -199,113 +198,18 @@ var edx = edx || {};
|
||||
},
|
||||
|
||||
/**
|
||||
* Once authentication has completed successfully, a user may need to:
|
||||
* Once authentication has completed successfully:
|
||||
*
|
||||
* - Enroll in a course.
|
||||
* - Update email opt-in preferences
|
||||
*
|
||||
* These actions are delegated from the authComplete function to additional
|
||||
* functions requiring authentication.
|
||||
* If we're in a third party auth pipeline, we must complete the pipeline.
|
||||
* Otherwise, redirect to the specified next step.
|
||||
*
|
||||
*/
|
||||
authComplete: function() {
|
||||
var emailOptIn = edx.student.account.EmailOptInInterface,
|
||||
queryParams = this.queryParams();
|
||||
|
||||
// Set the email opt in preference.
|
||||
if (!_.isUndefined(queryParams.emailOptIn) && queryParams.enrollmentAction) {
|
||||
emailOptIn.setPreference(
|
||||
decodeURIComponent(queryParams.courseId),
|
||||
queryParams.emailOptIn,
|
||||
this
|
||||
).always(this.enrollment);
|
||||
if (this.thirdPartyAuth && this.thirdPartyAuth.finishAuthUrl) {
|
||||
this.redirect(this.thirdPartyAuth.finishAuthUrl);
|
||||
// Note: the third party auth URL likely contains another redirect URL embedded inside
|
||||
} else {
|
||||
this.enrollment();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Designed to be invoked after authentication has completed. This function enrolls
|
||||
* the student as requested.
|
||||
*
|
||||
* - Enroll in a course.
|
||||
* - Add a course to the shopping cart.
|
||||
* - Be redirected to the dashboard / track selection page / shopping cart.
|
||||
*
|
||||
* This handler is triggered upon successful authentication,
|
||||
* either from the login or registration form. It checks
|
||||
* query string params, performs enrollment/shopping cart actions,
|
||||
* then redirects the user to the next page.
|
||||
*
|
||||
* The optional query string params are:
|
||||
*
|
||||
* ?next: If provided, redirect to this page upon successful auth.
|
||||
* Django uses this when an unauthenticated user accesses a view
|
||||
* decorated with @login_required.
|
||||
*
|
||||
* ?enrollment_action: Can be either "enroll" or "add_to_cart".
|
||||
* If you provide this param, you must also provide a `course_id` param;
|
||||
* otherwise, no action will be taken.
|
||||
*
|
||||
* ?course_id: The slash-separated course ID to enroll in or add to the cart.
|
||||
*
|
||||
*/
|
||||
enrollment: function() {
|
||||
var enrollment = edx.student.account.EnrollmentInterface,
|
||||
shoppingcart = edx.student.account.ShoppingCartInterface,
|
||||
redirectUrl = this.urls.dashboard,
|
||||
queryParams = this.queryParams();
|
||||
|
||||
if ( queryParams.enrollmentAction === 'enroll' && queryParams.courseId ) {
|
||||
var courseId = decodeURIComponent( queryParams.courseId );
|
||||
|
||||
// Determine where to redirect the user after auto-enrollment.
|
||||
if ( !queryParams.courseMode ) {
|
||||
/* Backwards compatibility with the original course details page.
|
||||
The old implementation did not specify the course mode for enrollment,
|
||||
so we'd always send the user to the "track selection" page.
|
||||
The track selection page would allow the user to select the course mode
|
||||
("verified", "honor", etc.) -- or, if the only course mode was "honor",
|
||||
it would redirect the user to the dashboard. */
|
||||
redirectUrl = this.urls.trackSelection + courseId + '/';
|
||||
} else if ( queryParams.courseMode === 'honor' || queryParams.courseMode === 'audit' ) {
|
||||
/* The newer version of the course details page allows the user
|
||||
to specify which course mode to enroll as. If the student has
|
||||
chosen "honor", we send them immediately to the dashboard
|
||||
rather than the payment flow. The user may decide to upgrade
|
||||
from the dashboard later. */
|
||||
redirectUrl = this.urls.dashboard;
|
||||
} else {
|
||||
/* If the user selected any other kind of course mode, send them
|
||||
to the payment/verification flow. */
|
||||
redirectUrl = this.urls.payment + courseId + '/';
|
||||
}
|
||||
|
||||
/* Attempt to auto-enroll the user in a free mode of the course,
|
||||
then redirect to the next location. */
|
||||
enrollment.enroll( courseId, redirectUrl );
|
||||
} else if ( queryParams.enrollmentAction === 'add_to_cart' && queryParams.courseId) {
|
||||
/*
|
||||
If this is a paid course, add it to the shopping cart and redirect
|
||||
the user to the "view cart" page.
|
||||
*/
|
||||
shoppingcart.addCourseToCart( decodeURIComponent( queryParams.courseId ) );
|
||||
} else {
|
||||
/*
|
||||
Otherwise, redirect the user to the next page
|
||||
Check for forwarding url and ensure that it isn't external.
|
||||
If not, use the default forwarding URL.
|
||||
*/
|
||||
if ( !_.isNull( queryParams.next ) ) {
|
||||
var next = decodeURIComponent( queryParams.next );
|
||||
|
||||
// Ensure that the URL is internal for security reasons
|
||||
if ( !window.isExternal( next ) ) {
|
||||
redirectUrl = next;
|
||||
}
|
||||
}
|
||||
|
||||
this.redirect( redirectUrl );
|
||||
this.redirect(this.nextUrl);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -314,25 +218,7 @@ var edx = edx || {};
|
||||
* @param {string} url The URL to redirect to.
|
||||
*/
|
||||
redirect: function( url ) {
|
||||
window.location.href = url;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve query params that we use post-authentication
|
||||
* to decide whether to enroll a student in a course, add
|
||||
* an item to the cart, or redirect.
|
||||
*
|
||||
* @return {object} The query params. If any param is not
|
||||
* provided, it will default to null.
|
||||
*/
|
||||
queryParams: function() {
|
||||
return {
|
||||
next: $.url( '?next' ),
|
||||
enrollmentAction: $.url( '?enrollment_action' ),
|
||||
courseId: $.url( '?course_id' ),
|
||||
courseMode: $.url( '?course_mode' ),
|
||||
emailOptIn: $.url( '?email_opt_in')
|
||||
};
|
||||
window.location.replace(url);
|
||||
},
|
||||
|
||||
form: {
|
||||
@@ -361,4 +247,4 @@ var edx = edx || {};
|
||||
}
|
||||
}
|
||||
});
|
||||
})(jQuery, _, _.str, Backbone, gettext);
|
||||
})(jQuery, _, _.str, Backbone, History);
|
||||
|
||||
170
lms/static/js/student_account/views/FinishAuthView.js
Normal file
170
lms/static/js/student_account/views/FinishAuthView.js
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Once authentication has completed successfully, we may need to:
|
||||
*
|
||||
* - Enroll in a course.
|
||||
* - Add a course to the shopping cart.
|
||||
* - Update email opt-in preferences
|
||||
*
|
||||
* These actions are implemented by this view.
|
||||
*
|
||||
* This view may be initialized with the following optional parameters:
|
||||
* - courseId: string ID of the course in which to auto-enroll the user
|
||||
* - enrollmentAction: Can be either "enroll" or "add_to_cart". If you provide
|
||||
* this param, you must also provide a `course_id` param; otherwise, no
|
||||
* action will be taken.
|
||||
* - courseMode: optional. The mode to enroll in, e.g. "honor"
|
||||
* - emailOptIn: "true" or "false". Whether or not the user has opted in to
|
||||
* emails from the course's organization.
|
||||
* - nextUrl: Redirect to this URL upon completion of all tasks, if possible
|
||||
* and safe to do so.
|
||||
*
|
||||
* One the actions have been completed, the user will be redirected to either:
|
||||
* - The track selection or payment page (if they've been enrolled in a course that needs this)
|
||||
* - The specified 'nextUrl' if safe, or
|
||||
* - The dashboard
|
||||
*/
|
||||
;(function (define, undefined) {
|
||||
'use strict';
|
||||
define([
|
||||
'underscore',
|
||||
'backbone',
|
||||
'gettext',
|
||||
'js/student_account/emailoptin',
|
||||
'js/student_account/enrollment',
|
||||
'js/student_account/shoppingcart'
|
||||
], function (_, Backbone, gettext, emailOptInInterface, enrollmentInterface, shoppingCartInterface) {
|
||||
// These are not yet converted to requireJS:
|
||||
var edx = window.edx || {};
|
||||
emailOptInInterface = emailOptInInterface || edx.student.account.EmailOptInInterface;
|
||||
enrollmentInterface = enrollmentInterface || edx.student.account.EnrollmentInterface;
|
||||
shoppingCartInterface = shoppingCartInterface || edx.student.account.ShoppingCartInterface;
|
||||
|
||||
var FinishAuthView = Backbone.View.extend({
|
||||
el: '#finish-auth-status',
|
||||
|
||||
urls: {
|
||||
finishAuth: '/account/finish_auth',
|
||||
defaultNextUrl: '/dashboard',
|
||||
payment: '/verify_student/start-flow/',
|
||||
trackSelection: '/course_modes/choose/'
|
||||
},
|
||||
|
||||
initialize: function( obj ) {
|
||||
var queryParams = {
|
||||
next: $.url( '?next' ),
|
||||
enrollmentAction: $.url( '?enrollment_action' ),
|
||||
courseId: $.url( '?course_id' ),
|
||||
courseMode: $.url( '?course_mode' ),
|
||||
emailOptIn: $.url( '?email_opt_in')
|
||||
};
|
||||
for (var key in queryParams) {
|
||||
if (queryParams[key]) {
|
||||
queryParams[key] = decodeURIComponent(queryParams[key]);
|
||||
}
|
||||
}
|
||||
this.courseId = queryParams.courseId;
|
||||
this.enrollmentAction = queryParams.enrollmentAction;
|
||||
this.courseMode = queryParams.courseMode;
|
||||
this.emailOptIn = queryParams.emailOptIn;
|
||||
this.nextUrl = this.urls.defaultNextUrl;
|
||||
if (queryParams.next) {
|
||||
// Ensure that the next URL is internal for security reasons
|
||||
if ( ! window.isExternal( queryParams.next ) ) {
|
||||
this.nextUrl = queryParams.next;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
try {
|
||||
var next = _.bind(this.enrollment, this);
|
||||
this.checkEmailOptIn(next);
|
||||
} catch(err) {
|
||||
this.updateTaskDescription(gettext("Error") + ": " + err.message);
|
||||
this.redirect(this.nextUrl);
|
||||
}
|
||||
},
|
||||
|
||||
updateTaskDescription: function(desc) {
|
||||
// We don't display any detailed status updates to the user
|
||||
// but we do log them to the console to help with debugging.
|
||||
console.log(desc);
|
||||
},
|
||||
|
||||
/**
|
||||
* Step 1:
|
||||
* Update the user's email preferences and then proceed to the next step
|
||||
*/
|
||||
checkEmailOptIn: function(next) {
|
||||
// Set the email opt in preference. this.emailOptIn is null or "true" or "false"
|
||||
if ((this.emailOptIn === "true" || this.emailOptIn === "false") && this.enrollmentAction) {
|
||||
this.updateTaskDescription(gettext("Saving your email preference"));
|
||||
emailOptInInterface
|
||||
.setPreference(this.courseId, this.emailOptIn)
|
||||
.always(next);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Step 2. Handle enrollment:
|
||||
* - Enroll in a course or add a course to the shopping cart.
|
||||
* - Be redirected to the dashboard / track selection page / shopping cart.
|
||||
*/
|
||||
enrollment: function() {
|
||||
var redirectUrl = this.nextUrl;
|
||||
|
||||
if ( this.enrollmentAction === 'enroll' && this.courseId ) {
|
||||
this.updateTaskDescription(gettext("Enrolling you in the selected course"));
|
||||
var courseId = decodeURIComponent( this.courseId );
|
||||
|
||||
// Determine where to redirect the user after auto-enrollment.
|
||||
if ( !this.courseMode ) {
|
||||
/* Backwards compatibility with the original course details page.
|
||||
The old implementation did not specify the course mode for enrollment,
|
||||
so we'd always send the user to the "track selection" page.
|
||||
The track selection page would allow the user to select the course mode
|
||||
("verified", "honor", etc.) -- or, if the only course mode was "honor",
|
||||
it would redirect the user to the dashboard. */
|
||||
redirectUrl = this.urls.trackSelection + courseId + '/';
|
||||
} else if ( this.courseMode === 'honor' || this.courseMode === 'audit' ) {
|
||||
/* The newer version of the course details page allows the user
|
||||
to specify which course mode to enroll as. If the student has
|
||||
chosen "honor", we send them immediately to the next URL
|
||||
rather than the payment flow. The user may decide to upgrade
|
||||
from the dashboard later. */
|
||||
} else {
|
||||
/* If the user selected any other kind of course mode, send them
|
||||
to the payment/verification flow. */
|
||||
redirectUrl = this.urls.payment + courseId + '/';
|
||||
}
|
||||
|
||||
/* Attempt to auto-enroll the user in a free mode of the course,
|
||||
then redirect to the next location. */
|
||||
enrollmentInterface.enroll( courseId, redirectUrl );
|
||||
} else if ( this.enrollmentAction === 'add_to_cart' && this.courseId) {
|
||||
/*
|
||||
If this is a paid course, add it to the shopping cart and redirect
|
||||
the user to the "view cart" page.
|
||||
*/
|
||||
this.updateTaskDescription(gettext("Adding the selected course to your cart"));
|
||||
shoppingCartInterface.addCourseToCart( this.courseId );
|
||||
} else {
|
||||
// Otherwise, redirect the user to the next page.
|
||||
this.redirect( redirectUrl );
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Redirect to a URL. Mainly useful for mocking out in tests.
|
||||
* @param {string} url The URL to redirect to.
|
||||
*/
|
||||
redirect: function( url ) {
|
||||
this.updateTaskDescription(gettext("Loading your courses"));
|
||||
window.location.replace(url);
|
||||
}
|
||||
});
|
||||
return FinishAuthView;
|
||||
});
|
||||
}).call(this, define || RequireJS.define);
|
||||
@@ -26,6 +26,7 @@ var edx = edx || {};
|
||||
preRender: function( data ) {
|
||||
this.providers = data.thirdPartyAuth.providers || [];
|
||||
this.currentProvider = data.thirdPartyAuth.currentProvider || '';
|
||||
this.errorMessage = data.thirdPartyAuth.errorMessage || '';
|
||||
this.platformName = data.platformName;
|
||||
this.resetModel = data.resetModel;
|
||||
|
||||
@@ -42,6 +43,7 @@ var edx = edx || {};
|
||||
context: {
|
||||
fields: fields,
|
||||
currentProvider: this.currentProvider,
|
||||
errorMessage: this.errorMessage,
|
||||
providers: this.providers,
|
||||
platformName: this.platformName
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ var edx = edx || {};
|
||||
preRender: function( data ) {
|
||||
this.providers = data.thirdPartyAuth.providers || [];
|
||||
this.currentProvider = data.thirdPartyAuth.currentProvider || '';
|
||||
this.errorMessage = data.thirdPartyAuth.errorMessage || '';
|
||||
this.platformName = data.platformName;
|
||||
|
||||
this.listenTo( this.model, 'sync', this.saveSuccess );
|
||||
@@ -38,6 +39,7 @@ var edx = edx || {};
|
||||
context: {
|
||||
fields: fields,
|
||||
currentProvider: this.currentProvider,
|
||||
errorMessage: this.errorMessage,
|
||||
providers: this.providers,
|
||||
platformName: this.platformName
|
||||
}
|
||||
|
||||
@@ -515,3 +515,26 @@ $sm-btn-linkedin: #0077b5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.finish-auth {
|
||||
@include box-sizing(border-box);
|
||||
@include outer-container;
|
||||
$grid-columns: 12;
|
||||
background: $white;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.finish-auth-inner {
|
||||
@include box-sizing(border-box);
|
||||
max-width: 650px;
|
||||
margin: 1em auto;
|
||||
}
|
||||
|
||||
#finish-auth-status {
|
||||
padding-top: 30px; // Make room for the absolutely positioned loading animation
|
||||
}
|
||||
|
||||
#finish-auth-status li:last-child {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,20 +65,17 @@ from microsite_configuration import microsite
|
||||
|
||||
$('#login-form').on('ajax:success', function(event, json, xhr) {
|
||||
if(json.success) {
|
||||
var u=decodeURI(window.location.search);
|
||||
var next = u.split("next=")[1];
|
||||
if (next != undefined) {
|
||||
// if next is undefined, decodeURI returns "undefined" causing a bad redirect.
|
||||
next = decodeURIComponent(next);
|
||||
var nextUrl = "${login_redirect_url}";
|
||||
if (json.redirect_url) {
|
||||
nextUrl = json.redirect_url; // Most likely third party auth completion. This trumps 'nextUrl' above.
|
||||
}
|
||||
if (next && !isExternal(next)) {
|
||||
location.href=next;
|
||||
} else if(json.redirect_url){
|
||||
location.href=json.redirect_url;
|
||||
if (!isExternal(nextUrl)) {
|
||||
location.href=nextUrl;
|
||||
} else {
|
||||
location.href="${reverse('dashboard')}";
|
||||
}
|
||||
} else if(json.hasOwnProperty('redirect')) {
|
||||
// Shibboleth authentication redirect requested by the server:
|
||||
var u=decodeURI(window.location.search);
|
||||
if (!isExternal(json.redirect)) { // a paranoid check. Our server is the one providing json.redirect
|
||||
location.href=json.redirect+u;
|
||||
@@ -162,6 +159,15 @@ from microsite_configuration import microsite
|
||||
<p class="instructions"> </p>
|
||||
</div>
|
||||
|
||||
% if third_party_auth_error:
|
||||
<div role="alert" class="status message third-party-auth-error is-shown" tabindex="-1">
|
||||
<h3 class="message-title">${_("An error occurred when signing you in to {platform_name}.").format(platform_name=platform_name)} </h3>
|
||||
<ul class="message-copy">
|
||||
<li>${third_party_auth_error}</li>
|
||||
</ul>
|
||||
</div>
|
||||
% endif
|
||||
|
||||
<p class="instructions sr">
|
||||
${_('Please provide the following information to log into your {platform_name} account. Required fields are noted by <strong class="indicator">bold text and an asterisk (*)</strong>.').format(platform_name=platform_name)}
|
||||
</p>
|
||||
@@ -196,15 +202,6 @@ from microsite_configuration import microsite
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
% if course_id and enrollment_action:
|
||||
<input type="hidden" name="enrollment_action" value="${enrollment_action | h}" />
|
||||
<input type="hidden" name="course_id" value="${course_id | h}" />
|
||||
% endif
|
||||
|
||||
% if email_opt_in:
|
||||
<input type="hidden" name="email_opt_in" value="${email_opt_in | h}" />
|
||||
% endif
|
||||
|
||||
<div class="form-actions">
|
||||
<button name="submit" type="submit" id="submit" class="action action-primary action-update login-button"></button>
|
||||
</div>
|
||||
|
||||
@@ -171,18 +171,7 @@ from branding import api as branding_api
|
||||
</html>
|
||||
|
||||
<%def name="login_query()">${
|
||||
u"?course_id={0}&enrollment_action={1}{course_mode}{email_opt_in}".format(
|
||||
urlquote_plus(course_id),
|
||||
urlquote_plus(enrollment_action),
|
||||
course_mode=(
|
||||
u"&course_mode=" + urlquote_plus(course_mode)
|
||||
if course_mode else ""
|
||||
),
|
||||
email_opt_in=(
|
||||
u"&email_opt_in=" + urlquote_plus(email_opt_in)
|
||||
if email_opt_in else ""
|
||||
)
|
||||
) if course_id and enrollment_action else ""
|
||||
u"?next={0}".format(urlquote_plus(login_redirect_url)) if login_redirect_url else ""
|
||||
}</%def>
|
||||
|
||||
<!-- Performance beacon for onload times -->
|
||||
|
||||
@@ -130,7 +130,7 @@ site_status_msg = get_site_status_msg(course_id)
|
||||
</li>
|
||||
% else:
|
||||
<li class="nav-global-04">
|
||||
<a class="cta cta-register" href="/register">${_("Register Now")}</a>
|
||||
<a class="cta cta-register" href="/register${login_query()}">${_("Register Now")}</a>
|
||||
</li>
|
||||
% endif
|
||||
% endif
|
||||
|
||||
@@ -54,8 +54,15 @@ import calendar
|
||||
});
|
||||
|
||||
$('#register-form').on('ajax:success', function(event, json, xhr) {
|
||||
var url = json.redirect_url || "${reverse('dashboard')}";
|
||||
location.href = url;
|
||||
var nextUrl = "${login_redirect_url}";
|
||||
if (json.redirect_url) {
|
||||
nextUrl = json.redirect_url; // Most likely third party auth completion. This trumps 'nextUrl' above.
|
||||
}
|
||||
if (!isExternal(nextUrl)) {
|
||||
location.href=nextUrl;
|
||||
} else {
|
||||
location.href="${reverse('dashboard')}";
|
||||
}
|
||||
});
|
||||
|
||||
$('#register-form').on('ajax:error', function(event, jqXHR, textStatus) {
|
||||
@@ -359,15 +366,6 @@ import calendar
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
% if course_id and enrollment_action:
|
||||
<input type="hidden" name="enrollment_action" value="${enrollment_action | h}" />
|
||||
<input type="hidden" name="course_id" value="${course_id | h}" />
|
||||
% endif
|
||||
|
||||
% if email_opt_in:
|
||||
<input type="hidden" name="email_opt_in" value="${email_opt_in | h }" />
|
||||
% endif
|
||||
|
||||
<div class="form-actions">
|
||||
<button name="submit" type="submit" id="submit" class="action action-primary action-update register-button">${_('Register')} <span class="orn-plus">+</span> ${_('Create My Account')}</button>
|
||||
</div>
|
||||
|
||||
51
lms/templates/student_account/finish_auth.html
Normal file
51
lms/templates/student_account/finish_auth.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%namespace name='static' file='/static_content.html'/>
|
||||
<%inherit file="/main.html" />
|
||||
|
||||
<%block name="pagetitle">${_("Please Wait")}</%block>
|
||||
|
||||
<%block name="js_extra">
|
||||
<script src="${static.url('js/vendor/backbone-min.js')}"></script>
|
||||
<%static:js group='utility'/>
|
||||
</%block>
|
||||
|
||||
<%block name="headextra">
|
||||
|
||||
<script>
|
||||
(function (require, define) {
|
||||
'use strict';
|
||||
define("js/student_account/views/finish_auth_factory",
|
||||
[
|
||||
'jquery', 'underscore', 'backbone',
|
||||
'js/student_account/views/FinishAuthView'
|
||||
],
|
||||
function ($, _, Backbone, FinishAuthView) {
|
||||
return function() {
|
||||
var view = new FinishAuthView({});
|
||||
view.render();
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
require(["js/student_account/views/finish_auth_factory"],
|
||||
function (factory) {
|
||||
factory();
|
||||
}
|
||||
);
|
||||
|
||||
}).call(this, require || RequireJS.require, define || RequireJS.define);
|
||||
</script>
|
||||
|
||||
</%block>
|
||||
|
||||
<div class="finish-auth">
|
||||
<div class="finish-auth-inner">
|
||||
<h1>${_('Please wait')}</h1>
|
||||
|
||||
<div class="loading-animation"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## This overwrites the "footer" block declared in main.html
|
||||
## with an empty block, effectively hiding the footer.
|
||||
<%block name="footer"/>
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="status already-authenticated-msg hidden">
|
||||
<% if (context.currentProvider) { %>
|
||||
<p class="message-copy">
|
||||
<%- _.sprintf( gettext("You've successfully signed into %(currentProvider)s, but your %(currentProvider)s account isn't linked with an %(platformName)s account. To link your accounts, go to your %(platformName)s account settings."), context ) %>
|
||||
<%- _.sprintf( gettext("You have successfully signed into %(currentProvider)s, but your %(currentProvider)s account does not have a linked %(platformName)s account. To link your accounts, sign in now using your %(platformName)s password."), context ) %>
|
||||
</p>
|
||||
<% } %>
|
||||
</div>
|
||||
@@ -20,6 +20,13 @@
|
||||
<ul class="message-copy"></ul>
|
||||
</div>
|
||||
|
||||
<% if (context.errorMessage) { %>
|
||||
<div class="status submission-error">
|
||||
<h4 class="message-title"><%- _.sprintf( gettext("An error occurred when signing you in to %(platformName)s."), context ) %></h4>
|
||||
<ul class="message-copy"><%- context.errorMessage %></ul>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<form id="login" class="login-form" tabindex="-1">
|
||||
|
||||
<div class="section-title lines">
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
class="login-register"
|
||||
data-initial-mode="${initial_mode}"
|
||||
data-third-party-auth='${third_party_auth|h}'
|
||||
data-next-url='${login_redirect_url|h}'
|
||||
data-platform-name='${platform_name}'
|
||||
data-login-form-desc='${login_form_desc|h}'
|
||||
data-registration-form-desc='${registration_form_desc|h}'
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
</div>
|
||||
|
||||
<form id="register" class="register-form" autocomplete="off" tabindex="-1">
|
||||
|
||||
<% if (context.errorMessage) { %>
|
||||
<div class="status submission-error">
|
||||
<h4 class="message-title"><%- gettext("An error occurred.") %></h4>
|
||||
<ul class="message-copy"><%- context.errorMessage %></ul>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (context.currentProvider) { %>
|
||||
<div class="status" aria-hidden="false">
|
||||
<p class="message-copy">
|
||||
|
||||
@@ -69,7 +69,7 @@ pyparsing==2.0.1
|
||||
python-memcached==1.48
|
||||
python-openid==2.2.5
|
||||
python-dateutil==2.1
|
||||
python-social-auth==0.1.23
|
||||
python-social-auth==0.2.7
|
||||
pytz==2015.2
|
||||
pysrt==0.4.7
|
||||
PyYAML==3.10
|
||||
|
||||
Reference in New Issue
Block a user