Commit e960adc0 by Renzo Lucioni

Merge pull request #8262 from open-craft/tpa-pipeline-consolidation

Cleanup of third-party login and auto-enrollment
parents 97979513 345fcabd
...@@ -10,6 +10,7 @@ from provider.oauth2.forms import ScopeChoiceField, ScopeMixin ...@@ -10,6 +10,7 @@ from provider.oauth2.forms import ScopeChoiceField, ScopeMixin
from provider.oauth2.models import Client from provider.oauth2.models import Client
from requests import HTTPError from requests import HTTPError
from social.backends import oauth as social_oauth from social.backends import oauth as social_oauth
from social.exceptions import AuthException
from third_party_auth import pipeline from third_party_auth import pipeline
...@@ -54,7 +55,7 @@ class AccessTokenExchangeForm(ScopeMixin, OAuthForm): ...@@ -54,7 +55,7 @@ class AccessTokenExchangeForm(ScopeMixin, OAuthForm):
if self._errors: if self._errors:
return {} return {}
backend = self.request.social_strategy.backend backend = self.request.backend
if not isinstance(backend, social_oauth.BaseOAuth2): if not isinstance(backend, social_oauth.BaseOAuth2):
raise OAuthValidationError( raise OAuthValidationError(
{ {
...@@ -88,8 +89,8 @@ class AccessTokenExchangeForm(ScopeMixin, OAuthForm): ...@@ -88,8 +89,8 @@ class AccessTokenExchangeForm(ScopeMixin, OAuthForm):
user = None user = None
try: try:
user = backend.do_auth(self.cleaned_data.get("access_token")) user = backend.do_auth(self.cleaned_data.get("access_token"), allow_inactive_user=True)
except HTTPError: except (HTTPError, AuthException):
pass pass
if user and isinstance(user, User): if user and isinstance(user, User):
self.cleaned_data["user"] = user self.cleaned_data["user"] = user
......
...@@ -24,8 +24,11 @@ class AccessTokenExchangeFormTest(AccessTokenExchangeTestMixin): ...@@ -24,8 +24,11 @@ class AccessTokenExchangeFormTest(AccessTokenExchangeTestMixin):
def setUp(self): def setUp(self):
super(AccessTokenExchangeFormTest, self).setUp() super(AccessTokenExchangeFormTest, self).setUp()
self.request = RequestFactory().post("dummy_url") self.request = RequestFactory().post("dummy_url")
redirect_uri = 'dummy_redirect_url'
SessionMiddleware().process_request(self.request) 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): def _assert_error(self, data, expected_error, expected_error_description):
form = AccessTokenExchangeForm(request=self.request, data=data) form = AccessTokenExchangeForm(request=self.request, data=data)
......
...@@ -20,6 +20,7 @@ from external_auth.views import ( ...@@ -20,6 +20,7 @@ from external_auth.views import (
shib_login, course_specific_login, course_specific_register, _flatten_to_ascii shib_login, course_specific_login, course_specific_register, _flatten_to_ascii
) )
from mock import patch from mock import patch
from urllib import urlencode
from student.views import create_account, change_enrollment from student.views import create_account, change_enrollment
from student.models import UserProfile, CourseEnrollment from student.models import UserProfile, CourseEnrollment
...@@ -169,7 +170,7 @@ class ShibSPTest(ModuleStoreTestCase): ...@@ -169,7 +170,7 @@ class ShibSPTest(ModuleStoreTestCase):
if idp == "https://idp.stanford.edu/" and remote_user == 'withmap@stanford.edu': if idp == "https://idp.stanford.edu/" and remote_user == 'withmap@stanford.edu':
self.assertIsInstance(response, HttpResponseRedirect) self.assertIsInstance(response, HttpResponseRedirect)
self.assertEqual(request.user, user_w_map) self.assertEqual(request.user, user_w_map)
self.assertEqual(response['Location'], '/') self.assertEqual(response['Location'], '/dashboard')
# verify logging: # verify logging:
self.assertEquals(len(audit_log_calls), 2) self.assertEquals(len(audit_log_calls), 2)
self._assert_shib_login_is_logged(audit_log_calls[0], remote_user) self._assert_shib_login_is_logged(audit_log_calls[0], remote_user)
...@@ -193,7 +194,7 @@ class ShibSPTest(ModuleStoreTestCase): ...@@ -193,7 +194,7 @@ class ShibSPTest(ModuleStoreTestCase):
self.assertIsNotNone(ExternalAuthMap.objects.get(user=user_wo_map)) self.assertIsNotNone(ExternalAuthMap.objects.get(user=user_wo_map))
self.assertIsInstance(response, HttpResponseRedirect) self.assertIsInstance(response, HttpResponseRedirect)
self.assertEqual(request.user, user_wo_map) self.assertEqual(request.user, user_wo_map)
self.assertEqual(response['Location'], '/') self.assertEqual(response['Location'], '/dashboard')
# verify logging: # verify logging:
self.assertEquals(len(audit_log_calls), 2) self.assertEquals(len(audit_log_calls), 2)
self._assert_shib_login_is_logged(audit_log_calls[0], remote_user) self._assert_shib_login_is_logged(audit_log_calls[0], remote_user)
...@@ -242,7 +243,7 @@ class ShibSPTest(ModuleStoreTestCase): ...@@ -242,7 +243,7 @@ class ShibSPTest(ModuleStoreTestCase):
self.assertTrue(inactive_user.is_active) self.assertTrue(inactive_user.is_active)
self.assertIsInstance(response, HttpResponseRedirect) self.assertIsInstance(response, HttpResponseRedirect)
self.assertEqual(request.user, inactive_user) self.assertEqual(request.user, inactive_user)
self.assertEqual(response['Location'], '/') self.assertEqual(response['Location'], '/dashboard')
# verify logging: # verify logging:
self.assertEquals(len(audit_log_calls), 3) self.assertEquals(len(audit_log_calls), 3)
self._assert_shib_login_is_logged(audit_log_calls[0], log_user_string) self._assert_shib_login_is_logged(audit_log_calls[0], log_user_string)
...@@ -549,29 +550,20 @@ class ShibSPTest(ModuleStoreTestCase): ...@@ -549,29 +550,20 @@ class ShibSPTest(ModuleStoreTestCase):
# no enrollment before trying # no enrollment before trying
self.assertFalse(CourseEnrollment.is_enrolled(student, course.id)) self.assertFalse(CourseEnrollment.is_enrolled(student, course.id))
self.client.logout() self.client.logout()
params = [
('course_id', course.id.to_deprecated_string()),
('enrollment_action', 'enroll'),
('next', '/testredirect')
]
request_kwargs = {'path': '/shib-login/', request_kwargs = {'path': '/shib-login/',
'data': {'enrollment_action': 'enroll', 'course_id': course.id.to_deprecated_string(), 'next': '/testredirect'}, 'data': dict(params),
'follow': False, 'follow': False,
'REMOTE_USER': 'testuser@stanford.edu', 'REMOTE_USER': 'testuser@stanford.edu',
'Shib-Identity-Provider': 'https://idp.stanford.edu/'} 'Shib-Identity-Provider': 'https://idp.stanford.edu/'}
response = self.client.get(**request_kwargs) 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.status_code, 302)
self.assertEqual(response['location'], 'http://testserver/testredirect') self.assertEqual(response['location'], 'http://testserver/account/finish_auth?{}'.format(urlencode(params)))
# 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))
class ShibUtilFnTest(TestCase): class ShibUtilFnTest(TestCase):
......
...@@ -22,6 +22,7 @@ from django.core.exceptions import ValidationError ...@@ -22,6 +22,7 @@ from django.core.exceptions import ValidationError
if settings.FEATURES.get('AUTH_USE_CAS'): if settings.FEATURES.get('AUTH_USE_CAS'):
from django_cas.views import login as django_cas_login 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 student.models import UserProfile
from django.http import HttpResponse, HttpResponseRedirect, HttpRequest, HttpResponseForbidden from django.http import HttpResponse, HttpResponseRedirect, HttpRequest, HttpResponseForbidden
...@@ -118,7 +119,8 @@ def openid_login_complete(request, ...@@ -118,7 +119,8 @@ def openid_login_complete(request,
external_domain, external_domain,
details, details,
details.get('email', ''), details.get('email', ''),
fullname fullname,
retfun=functools.partial(redirect, get_next_url_for_login_page(request)),
) )
return render_failure(request, 'Openid failure') return render_failure(request, 'Openid failure')
...@@ -236,14 +238,6 @@ def _external_login_or_signup(request, ...@@ -236,14 +238,6 @@ def _external_login_or_signup(request,
login(request, user) login(request, user)
request.session.set_expiry(0) 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']: if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
AUDIT_LOG.info(u"Login success - user.id: {0}".format(user.id)) AUDIT_LOG.info(u"Login success - user.id: {0}".format(user.id))
else: else:
...@@ -449,9 +443,7 @@ def ssl_login(request): ...@@ -449,9 +443,7 @@ def ssl_login(request):
(_user, email, fullname) = _ssl_dn_extract_info(cert) (_user, email, fullname) = _ssl_dn_extract_info(cert)
redirect_to = request.GET.get('next') redirect_to = get_next_url_for_login_page(request)
if not redirect_to:
redirect_to = '/'
retfun = functools.partial(redirect, redirect_to) retfun = functools.partial(redirect, redirect_to)
return _external_login_or_signup( return _external_login_or_signup(
request, request,
...@@ -528,10 +520,8 @@ def shib_login(request): ...@@ -528,10 +520,8 @@ def shib_login(request):
fullname = shib['displayName'] if shib['displayName'] else u'%s %s' % (shib['givenName'], shib['sn']) fullname = shib['displayName'] if shib['displayName'] else u'%s %s' % (shib['givenName'], shib['sn'])
redirect_to = request.REQUEST.get('next') redirect_to = get_next_url_for_login_page(request)
retfun = None retfun = functools.partial(_safe_postlogin_redirect, redirect_to, request.get_host())
if redirect_to:
retfun = functools.partial(_safe_postlogin_redirect, redirect_to, request.get_host())
return _external_login_or_signup( return _external_login_or_signup(
request, request,
...@@ -558,31 +548,6 @@ def _safe_postlogin_redirect(redirect_to, safehost, default_redirect='/'): ...@@ -558,31 +548,6 @@ def _safe_postlogin_redirect(redirect_to, safehost, default_redirect='/'):
return redirect(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): def course_specific_login(request, course_id):
""" """
Dispatcher function for selecting the specific login method Dispatcher function for selecting the specific login method
......
...@@ -4,9 +4,11 @@ from datetime import datetime ...@@ -4,9 +4,11 @@ from datetime import datetime
from pytz import UTC from pytz import UTC
from django.utils.http import cookie_date from django.utils.http import cookie_date
from django.conf import settings 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 verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=F0401
from course_modes.models import CourseMode 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): def set_logged_in_cookie(request, response):
...@@ -199,3 +201,70 @@ def check_verify_status_by_course(user, course_enrollment_pairs, all_course_mode ...@@ -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") status_by_course[key]['verification_good_until'] = recent_verification_datetime.strftime("%m/%d/%Y")
return status_by_course 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): ...@@ -278,24 +278,6 @@ class LoginTest(TestCase):
self.assertIsNone(response_content["redirect_url"]) self.assertIsNone(response_content["redirect_url"])
self._assert_response(response, success=True) 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): def _login_response(self, email, password, patched_audit_log='student.views.AUDIT_LOG', extra_post_params=None):
''' Post the login info ''' ''' Post the login info '''
post_params = {'email': email, 'password': password} post_params = {'email': email, 'password': password}
......
...@@ -819,38 +819,6 @@ class ChangeEnrollmentViewTest(ModuleStoreTestCase): ...@@ -819,38 +819,6 @@ class ChangeEnrollmentViewTest(ModuleStoreTestCase):
self.assertEqual(enrollment_mode, u'honor') 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): class AnonymousLookupTable(ModuleStoreTestCase):
""" """
Tests for anonymous_id_functions Tests for anonymous_id_functions
......
"""
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 = {}
...@@ -36,7 +36,7 @@ class BaseProvider(object): ...@@ -36,7 +36,7 @@ class BaseProvider(object):
return '%s.%s' % (cls.BACKEND_CLASS.__module__, cls.BACKEND_CLASS.__name__) return '%s.%s' % (cls.BACKEND_CLASS.__module__, cls.BACKEND_CLASS.__name__)
@classmethod @classmethod
def get_email(cls, unused_provider_details): def get_email(cls, provider_details):
"""Gets user's email address. """Gets user's email address.
Provider responses can contain arbitrary data. This method can be Provider responses can contain arbitrary data. This method can be
...@@ -44,16 +44,16 @@ class BaseProvider(object): ...@@ -44,16 +44,16 @@ class BaseProvider(object):
extracted by the social_details pipeline step. extracted by the social_details pipeline step.
Args: 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. user passed back by the provider.
Returns: Returns:
String or None. The user's email address, if any. String or None. The user's email address, if any.
""" """
return None return provider_details.get('email')
@classmethod @classmethod
def get_name(cls, unused_provider_details): def get_name(cls, provider_details):
"""Gets user's name. """Gets user's name.
Provider responses can contain arbitrary data. This method can be Provider responses can contain arbitrary data. This method can be
...@@ -61,13 +61,13 @@ class BaseProvider(object): ...@@ -61,13 +61,13 @@ class BaseProvider(object):
extracted by the social_details pipeline step. extracted by the social_details pipeline step.
Args: 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. user passed back by the provider.
Returns: Returns:
String or None. The user's full name, if any. String or None. The user's full name, if any.
""" """
return None return provider_details.get('fullname')
@classmethod @classmethod
def get_register_form_data(cls, pipeline_kwargs): def get_register_form_data(cls, pipeline_kwargs):
...@@ -121,14 +121,6 @@ class GoogleOauth2(BaseProvider): ...@@ -121,14 +121,6 @@ class GoogleOauth2(BaseProvider):
'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET': None, '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): class LinkedInOauth2(BaseProvider):
"""Provider for LinkedIn's Oauth2 auth system.""" """Provider for LinkedIn's Oauth2 auth system."""
...@@ -141,14 +133,6 @@ class LinkedInOauth2(BaseProvider): ...@@ -141,14 +133,6 @@ class LinkedInOauth2(BaseProvider):
'SOCIAL_AUTH_LINKEDIN_OAUTH2_SECRET': None, '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): class FacebookOauth2(BaseProvider):
"""Provider for LinkedIn's Oauth2 auth system.""" """Provider for LinkedIn's Oauth2 auth system."""
...@@ -161,14 +145,6 @@ class FacebookOauth2(BaseProvider): ...@@ -161,14 +145,6 @@ class FacebookOauth2(BaseProvider):
'SOCIAL_AUTH_FACEBOOK_SECRET': None, '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): class Registry(object):
"""Singleton registry of third-party auth providers. """Singleton registry of third-party auth providers.
......
...@@ -46,7 +46,7 @@ If true, it: ...@@ -46,7 +46,7 @@ If true, it:
from . import provider 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 = ( _MIDDLEWARE_CLASSES = (
'third_party_auth.middleware.ExceptionMiddleware', 'third_party_auth.middleware.ExceptionMiddleware',
) )
...@@ -105,6 +105,7 @@ def _set_global_settings(django_settings): ...@@ -105,6 +105,7 @@ def _set_global_settings(django_settings):
'social.pipeline.social_auth.social_user', 'social.pipeline.social_auth.social_user',
'third_party_auth.pipeline.associate_by_email_if_login_api', 'third_party_auth.pipeline.associate_by_email_if_login_api',
'social.pipeline.user.get_username', 'social.pipeline.user.get_username',
'third_party_auth.pipeline.set_pipeline_timeout',
'third_party_auth.pipeline.ensure_user_information', 'third_party_auth.pipeline.ensure_user_information',
'social.pipeline.user.create_user', 'social.pipeline.user.create_user',
'social.pipeline.social_auth.associate_user', 'social.pipeline.social_auth.associate_user',
...@@ -112,7 +113,6 @@ def _set_global_settings(django_settings): ...@@ -112,7 +113,6 @@ def _set_global_settings(django_settings):
'social.pipeline.user.user_details', 'social.pipeline.user.user_details',
'third_party_auth.pipeline.set_logged_in_cookie', 'third_party_auth.pipeline.set_logged_in_cookie',
'third_party_auth.pipeline.login_analytics', 'third_party_auth.pipeline.login_analytics',
'third_party_auth.pipeline.change_enrollment',
) )
# We let the user specify their email address during signup. # We let the user specify their email address during signup.
...@@ -123,6 +123,13 @@ def _set_global_settings(django_settings): ...@@ -123,6 +123,13 @@ def _set_global_settings(django_settings):
# enable this when you want to get stack traces rather than redirections. # enable this when you want to get stack traces rather than redirections.
django_settings.SOCIAL_AUTH_RAISE_EXCEPTIONS = False 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. # Context processors required under Django.
django_settings.SOCIAL_AUTH_UUID_LENGTH = 4 django_settings.SOCIAL_AUTH_UUID_LENGTH = 4
django_settings.TEMPLATE_CONTEXT_PROCESSORS += ( django_settings.TEMPLATE_CONTEXT_PROCESSORS += (
...@@ -148,6 +155,9 @@ def _set_provider_settings(django_settings, enabled_providers, auth_info): ...@@ -148,6 +155,9 @@ def _set_provider_settings(django_settings, enabled_providers, auth_info):
def apply_settings(auth_info, django_settings): def apply_settings(auth_info, django_settings):
"""Applies settings from auth_info dict to django_settings module.""" """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_names = auth_info.keys()
provider.Registry.configure_once(provider_names) provider.Registry.configure_once(provider_names)
enabled_providers = provider.Registry.enabled() enabled_providers = provider.Registry.enabled()
......
# -*- 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): ...@@ -66,7 +66,7 @@ class ThirdPartyOAuthTestMixin(object):
class ThirdPartyOAuthTestMixinFacebook(object): class ThirdPartyOAuthTestMixinFacebook(object):
"""Tests oauth with the Facebook backend""" """Tests oauth with the Facebook backend"""
BACKEND = "facebook" 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 # In facebook responses, the "id" field is used as the user's identifier
UID_FIELD = "id" UID_FIELD = "id"
...@@ -74,6 +74,6 @@ class ThirdPartyOAuthTestMixinFacebook(object): ...@@ -74,6 +74,6 @@ class ThirdPartyOAuthTestMixinFacebook(object):
class ThirdPartyOAuthTestMixinGoogle(object): class ThirdPartyOAuthTestMixinGoogle(object):
"""Tests oauth with the Google backend""" """Tests oauth with the Google backend"""
BACKEND = "google-oauth2" 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 # In google-oauth2 responses, the "email" field is used as the user's identifier
UID_FIELD = "email" UID_FIELD = "email"
...@@ -2,8 +2,10 @@ ...@@ -2,8 +2,10 @@
from django.conf.urls import include, patterns, url from django.conf.urls import include, patterns, url
from .views import inactive_user_view
urlpatterns = patterns( urlpatterns = patterns(
'', '',
url(r'^auth/inactive', inactive_user_view),
url(r'^auth/', include('social.apps.django_app.urls', namespace='social')), url(r'^auth/', include('social.apps.django_app.urls', namespace='social')),
) )
"""
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): ...@@ -80,7 +80,7 @@ class FieldsMixin(object):
query = self.q(css='.u-field-{} .u-field-message'.format(field_id)) query = self.q(css='.u-field-{} .u-field-message'.format(field_id))
return query.text[0] if query.present else None 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. Wait for a message to appear in a field.
""" """
......
...@@ -187,10 +187,14 @@ class CombinedLoginAndRegisterPage(PageObject): ...@@ -187,10 +187,14 @@ class CombinedLoginAndRegisterPage(PageObject):
""" """
# Fill in the form # Fill in the form
self.wait_for_element_visibility('#register-email', 'Email field is shown') self.wait_for_element_visibility('#register-email', 'Email field is shown')
self.q(css="#register-email").fill(email) if email:
self.q(css="#register-name").fill(full_name) self.q(css="#register-email").fill(email)
self.q(css="#register-username").fill(username) if full_name:
self.q(css="#register-password").fill(password) 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: if country:
self.q(css="#register-country option[value='{country}']".format(country=country)).click() self.q(css="#register-country option[value='{country}']".format(country=country)).click()
if (terms_of_service): if (terms_of_service):
...@@ -220,6 +224,16 @@ class CombinedLoginAndRegisterPage(PageObject): ...@@ -220,6 +224,16 @@ class CombinedLoginAndRegisterPage(PageObject):
# Submit it # Submit it
self.q(css=".login-button").click() 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): def password_reset(self, email):
"""Navigates to, fills in, and submits the password reset form. """Navigates to, fills in, and submits the password reset form.
...@@ -269,6 +283,21 @@ class CombinedLoginAndRegisterPage(PageObject): ...@@ -269,6 +283,21 @@ class CombinedLoginAndRegisterPage(PageObject):
return "password-reset" return "password-reset"
@property @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): def errors(self):
"""Return a list of errors displayed to the user. """ """Return a list of errors displayed to the user. """
return self.q(css=".submission-error li").text return self.q(css=".submission-error li").text
...@@ -294,3 +323,15 @@ class CombinedLoginAndRegisterPage(PageObject): ...@@ -294,3 +323,15 @@ class CombinedLoginAndRegisterPage(PageObject):
success = self.success success = self.success
return (bool(success), success) return (bool(success), success)
return Promise(_check_func, "Success message is visible").fulfill() 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): ...@@ -177,6 +177,7 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest):
{ {
'title': 'Connected Accounts', 'title': 'Connected Accounts',
'fields': [ 'fields': [
'Dummy',
'Facebook', 'Facebook',
'Google', 'Google',
] ]
...@@ -211,7 +212,7 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest): ...@@ -211,7 +212,7 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest):
for new_value in new_valid_values: for new_value in new_valid_values:
self.assertEqual(self.account_settings_page.value_for_text_field(field_id, new_value), new_value) 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: if assert_after_reload:
self.browser.refresh() self.browser.refresh()
self.assertEqual(self.account_settings_page.value_for_text_field(field_id), new_value) self.assertEqual(self.account_settings_page.value_for_text_field(field_id), new_value)
...@@ -227,7 +228,7 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest): ...@@ -227,7 +228,7 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest):
for new_value in new_values: for new_value in new_values:
self.assertEqual(self.account_settings_page.value_for_dropdown_field(field_id, new_value), new_value) 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: if reloads_on_save:
self.account_settings_page.wait_for_loading_indicator() self.account_settings_page.wait_for_loading_indicator()
else: else:
...@@ -242,7 +243,7 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest): ...@@ -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.title_for_field(field_id), title)
self.assertEqual(self.account_settings_page.link_title_for_link_field(field_id), link_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.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): def test_username_field(self):
""" """
......
...@@ -18,6 +18,7 @@ from ..helpers import ( ...@@ -18,6 +18,7 @@ from ..helpers import (
select_option_by_value, select_option_by_value,
element_has_text element_has_text
) )
from ...pages.lms.account_settings import AccountSettingsPage
from ...pages.lms.auto_auth import AutoAuthPage from ...pages.lms.auto_auth import AutoAuthPage
from ...pages.lms.create_mode import ModeCreationPage from ...pages.lms.create_mode import ModeCreationPage
from ...pages.common.logout import LogoutPage from ...pages.common.logout import LogoutPage
...@@ -131,6 +132,46 @@ class LoginFromCombinedPageTest(UniqueCourseTest): ...@@ -131,6 +132,46 @@ class LoginFromCombinedPageTest(UniqueCourseTest):
self.login_page.wait_for_errors() 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): def _create_unique_user(self):
""" """
Create a new user with a unique name and email. Create a new user with a unique name and email.
...@@ -226,6 +267,50 @@ class RegisterFromCombinedPageTest(UniqueCourseTest): ...@@ -226,6 +267,50 @@ class RegisterFromCombinedPageTest(UniqueCourseTest):
self.register_page.visit().toggle_form() self.register_page.visit().toggle_form()
self.assertEqual(self.register_page.current_form, "login") 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') @attr('shard_4')
class PayAndVerifyTest(EventsTestMixin, UniqueCourseTest): class PayAndVerifyTest(EventsTestMixin, UniqueCourseTest):
......
...@@ -2468,59 +2468,6 @@ CREATE TABLE `shoppingcart_registrationcoderedemption` ( ...@@ -2468,59 +2468,6 @@ CREATE TABLE `shoppingcart_registrationcoderedemption` (
CONSTRAINT `registration_code_id_refs_id_4d01e47b` FOREIGN KEY (`registration_code_id`) REFERENCES `shoppingcart_courseregistrationcode` (`id`) CONSTRAINT `registration_code_id_refs_id_4d01e47b` FOREIGN KEY (`registration_code_id`) REFERENCES `shoppingcart_courseregistrationcode` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8; ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */; /*!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`; DROP TABLE IF EXISTS `south_migrationhistory`;
/*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */; /*!40101 SET character_set_client = utf8 */;
......
"""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()
}
from django.conf.urls import patterns, url from django.conf.urls import patterns, url
from django.conf import settings from django.conf import settings
urlpatterns = [] urlpatterns = []
if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'): if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'):
...@@ -14,5 +13,6 @@ if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'): ...@@ -14,5 +13,6 @@ if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'):
urlpatterns += patterns( urlpatterns += patterns(
'student_account.views', 'student_account.views',
url(r'^finish_auth$', 'finish_auth', name='finish_auth'),
url(r'^settings$', 'account_settings', name='account_settings'), url(r'^settings$', 'account_settings', name='account_settings'),
) )
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
import logging import logging
import json import json
from ipware.ip import get_ip
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
...@@ -19,12 +18,9 @@ from django.views.decorators.csrf import ensure_csrf_cookie ...@@ -19,12 +18,9 @@ from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from lang_pref.api import released_languages 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 edxmako.shortcuts import render_to_response
from microsite_configuration import microsite from microsite_configuration import microsite
from embargo import api as embargo_api
from external_auth.login_and_register import ( from external_auth.login_and_register import (
login as external_auth_login, login as external_auth_login,
register as external_auth_register register as external_auth_register
...@@ -34,16 +30,13 @@ from student.views import ( ...@@ -34,16 +30,13 @@ from student.views import (
signin_user as old_login_view, signin_user as old_login_view,
register_user as old_register_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 import third_party_auth
from third_party_auth import pipeline from third_party_auth import pipeline
from util.bad_request_rate_limiter import BadRequestRateLimiter 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.accounts.api import request_password_change
from openedx.core.djangoapps.user_api.errors import UserNotFound 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") AUDIT_LOG = logging.getLogger("audit")
...@@ -61,9 +54,12 @@ def login_and_registration_form(request, initial_mode="login"): ...@@ -61,9 +54,12 @@ def login_and_registration_form(request, initial_mode="login"):
initial_mode (string): Either "login" or "register". 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 we're already logged in, redirect to the dashboard
if request.user.is_authenticated(): if request.user.is_authenticated():
return redirect(reverse('dashboard')) return redirect(redirect_to)
# Retrieve the form descriptions from the user API # Retrieve the form descriptions from the user API
form_descriptions = _get_form_descriptions(request) form_descriptions = _get_form_descriptions(request)
...@@ -83,9 +79,10 @@ def login_and_registration_form(request, initial_mode="login"): ...@@ -83,9 +79,10 @@ def login_and_registration_form(request, initial_mode="login"):
# Otherwise, render the combined login/registration page # Otherwise, render the combined login/registration page
context = { 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, 'disable_courseware_js': True,
'initial_mode': initial_mode, '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, 'platform_name': settings.PLATFORM_NAME,
'responsive': True, 'responsive': True,
...@@ -96,12 +93,6 @@ def login_and_registration_form(request, initial_mode="login"): ...@@ -96,12 +93,6 @@ def login_and_registration_form(request, initial_mode="login"):
'login_form_desc': form_descriptions['login'], 'login_form_desc': form_descriptions['login'],
'registration_form_desc': form_descriptions['registration'], 'registration_form_desc': form_descriptions['registration'],
'password_reset_form_desc': form_descriptions['password_reset'], '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) return render_to_response('student_account/login_and_register.html', context)
...@@ -157,12 +148,14 @@ def password_change_request_handler(request): ...@@ -157,12 +148,14 @@ def password_change_request_handler(request):
return HttpResponseBadRequest(_("No email address provided.")) 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. """Context for third party auth providers and the currently running pipeline.
Arguments: Arguments:
request (HttpRequest): The request, used to determine if a pipeline request (HttpRequest): The request, used to determine if a pipeline
is currently running. is currently running.
redirect_to: The URL to send the user to following successful
authentication.
Returns: Returns:
dict dict
...@@ -170,72 +163,43 @@ def _third_party_auth_context(request): ...@@ -170,72 +163,43 @@ def _third_party_auth_context(request):
""" """
context = { context = {
"currentProvider": None, "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(): if third_party_auth.is_enabled():
context["providers"] = [ context["providers"] = [
{ {
"name": enabled.NAME, "name": enabled.NAME,
"iconClass": enabled.ICON_CLASS, "iconClass": enabled.ICON_CLASS,
"loginUrl": login_urls[enabled.NAME], "loginUrl": pipeline.get_login_url(
"registerUrl": register_urls[enabled.NAME] 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() 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: if running_pipeline is not None:
current_provider = third_party_auth.provider.Registry.get_by_backend_name( current_provider = third_party_auth.provider.Registry.get_by_backend_name(
running_pipeline.get('backend') running_pipeline.get('backend')
) )
context["currentProvider"] = current_provider.NAME 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 return context
...@@ -326,6 +290,39 @@ def account_settings(request): ...@@ -326,6 +290,39 @@ def account_settings(request):
return render_to_response('student_account/account_settings.html', account_settings_context(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): def account_settings_context(request):
""" Context for the account settings page. """ Context for the account settings page.
......
...@@ -532,6 +532,9 @@ X_FRAME_OPTIONS = ENV_TOKENS.get('X_FRAME_OPTIONS', X_FRAME_OPTIONS) ...@@ -532,6 +532,9 @@ X_FRAME_OPTIONS = ENV_TOKENS.get('X_FRAME_OPTIONS', X_FRAME_OPTIONS)
##### Third-party auth options ################################################ ##### Third-party auth options ################################################
THIRD_PARTY_AUTH = AUTH_TOKENS.get('THIRD_PARTY_AUTH', THIRD_PARTY_AUTH) 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 ############## ##### OAUTH2 Provider ##############
if FEATURES.get('ENABLE_OAUTH2_PROVIDER'): if FEATURES.get('ENABLE_OAUTH2_PROVIDER'):
OAUTH_OIDC_ISSUER = ENV_TOKENS['OAUTH_OIDC_ISSUER'] OAUTH_OIDC_ISSUER = ENV_TOKENS['OAUTH_OIDC_ISSUER']
......
...@@ -118,6 +118,7 @@ ...@@ -118,6 +118,7 @@
}, },
"SECRET_KEY": "", "SECRET_KEY": "",
"THIRD_PARTY_AUTH": { "THIRD_PARTY_AUTH": {
"Dummy": {},
"Google": { "Google": {
"SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": "test", "SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": "test",
"SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test" "SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test"
......
...@@ -79,6 +79,7 @@ ...@@ -79,6 +79,7 @@
"ENABLE_INSTRUCTOR_ANALYTICS": true, "ENABLE_INSTRUCTOR_ANALYTICS": true,
"ENABLE_S3_GRADE_DOWNLOADS": true, "ENABLE_S3_GRADE_DOWNLOADS": true,
"ENABLE_THIRD_PARTY_AUTH": true, "ENABLE_THIRD_PARTY_AUTH": true,
"ENABLE_DUMMY_THIRD_PARTY_AUTH_PROVIDER": true,
"ENABLE_COMBINED_LOGIN_REGISTRATION": true, "ENABLE_COMBINED_LOGIN_REGISTRATION": true,
"PREVIEW_LMS_BASE": "localhost:8003", "PREVIEW_LMS_BASE": "localhost:8003",
"SUBDOMAIN_BRANDING": false, "SUBDOMAIN_BRANDING": false,
......
...@@ -1556,6 +1556,10 @@ PIPELINE_JS = { ...@@ -1556,6 +1556,10 @@ PIPELINE_JS = {
'certificates_wv': { 'certificates_wv': {
'source_filenames': certificates_web_view_js, 'source_filenames': certificates_web_view_js,
'output_filename': 'js/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 = { ...@@ -242,11 +242,13 @@ THIRD_PARTY_AUTH = {
"SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test", "SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test",
}, },
"Facebook": { "Facebook": {
"SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": "test", "SOCIAL_AUTH_FACEBOOK_KEY": "test",
"SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test", "SOCIAL_AUTH_FACEBOOK_SECRET": "test",
}, },
} }
FEATURES['ENABLE_DUMMY_THIRD_PARTY_AUTH_PROVIDER'] = True
################################## OPENID ##################################### ################################## OPENID #####################################
FEATURES['AUTH_USE_OPENID'] = True FEATURES['AUTH_USE_OPENID'] = True
FEATURES['AUTH_USE_OPENID_PROVIDER'] = True FEATURES['AUTH_USE_OPENID_PROVIDER'] = True
......
...@@ -449,7 +449,6 @@ ...@@ -449,7 +449,6 @@
'jquery', 'jquery',
'underscore', 'underscore',
'backbone', 'backbone',
'gettext',
'history', 'history',
'utility', 'utility',
'js/student_account/views/LoginView', 'js/student_account/views/LoginView',
...@@ -458,10 +457,7 @@ ...@@ -458,10 +457,7 @@
'js/student_account/models/LoginModel', 'js/student_account/models/LoginModel',
'js/student_account/models/PasswordResetModel', 'js/student_account/models/PasswordResetModel',
'js/student_account/models/RegisterModel', 'js/student_account/models/RegisterModel',
'js/student_account/views/FormView', 'js/student_account/views/FormView'
'js/student_account/emailoptin',
'js/student_account/enrollment',
'js/student_account/shoppingcart',
] ]
}, },
'js/verify_student/models/verification_model': { 'js/verify_student/models/verification_model': {
...@@ -598,6 +594,7 @@ ...@@ -598,6 +594,7 @@
'lms/include/js/spec/instructor_dashboard/student_admin_spec.js', '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/account_spec.js',
'lms/include/js/spec/student_account/access_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/login_spec.js',
'lms/include/js/spec/student_account/register_spec.js', 'lms/include/js/spec/student_account/register_spec.js',
'lms/include/js/spec/student_account/password_reset_spec.js', 'lms/include/js/spec/student_account/password_reset_spec.js',
......
...@@ -8,9 +8,8 @@ define([ ...@@ -8,9 +8,8 @@ define([
'js/student_account/shoppingcart', 'js/student_account/shoppingcart',
'js/student_account/emailoptin' 'js/student_account/emailoptin'
], function($, TemplateHelpers, AjaxHelpers, AccessView, FormView, EnrollmentInterface, ShoppingCartInterface) { ], function($, TemplateHelpers, AjaxHelpers, AccessView, FormView, EnrollmentInterface, ShoppingCartInterface) {
"use strict";
describe('edx.student.account.AccessView', function() { describe('edx.student.account.AccessView', function() {
'use strict';
var requests = null, var requests = null,
view = null, view = null,
FORM_DESCRIPTION = { FORM_DESCRIPTION = {
...@@ -41,10 +40,15 @@ define([ ...@@ -41,10 +40,15 @@ define([
} }
] ]
}, },
FORWARD_URL = '/courseware/next', FORWARD_URL = (
COURSE_KEY = 'edx/DemoX/Fall'; '/account/finish_auth' +
'?course_id=edx%2FDemoX%2FFall' +
var ajaxSpyAndInitialize = function(that, mode) { '&enrollment_action=enroll' +
'&next=%2Fdashboard'
),
THIRD_PARTY_COMPLETE_URL = '/auth/complete/provider/';
var ajaxSpyAndInitialize = function(that, mode, nextUrl, finishAuthUrl) {
// Spy on AJAX requests // Spy on AJAX requests
requests = AjaxHelpers.requests(that); requests = AjaxHelpers.requests(that);
...@@ -53,8 +57,10 @@ define([ ...@@ -53,8 +57,10 @@ define([
mode: mode, mode: mode,
thirdPartyAuth: { thirdPartyAuth: {
currentProvider: null, currentProvider: null,
providers: [] providers: [],
finishAuthUrl: finishAuthUrl
}, },
nextUrl: nextUrl, // undefined for default
platformName: 'edX', platformName: 'edX',
loginFormDesc: FORM_DESCRIPTION, loginFormDesc: FORM_DESCRIPTION,
registrationFormDesc: FORM_DESCRIPTION, registrationFormDesc: FORM_DESCRIPTION,
...@@ -84,20 +90,6 @@ define([ ...@@ -84,20 +90,6 @@ define([
view.toggleForm(changeEvent); 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() { beforeEach(function() {
setFixtures('<div id="login-and-registration-container"></div>'); setFixtures('<div id="login-and-registration-container"></div>');
TemplateHelpers.installTemplate('templates/student_account/access'); TemplateHelpers.installTemplate('templates/student_account/access');
...@@ -156,98 +148,29 @@ define([ ...@@ -156,98 +148,29 @@ define([
expect($("#password-reset-form")).not.toHaveClass('hidden'); expect($("#password-reset-form")).not.toHaveClass('hidden');
}); });
it('enrolls the user on auth complete', function() { it('redirects the user to the dashboard 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'); ajaxSpyAndInitialize(this, 'register');
// Simulate providing "add to cart" query string params // Trigger auth complete
setFakeQueryParams({
'?enrollment_action': 'add_to_cart',
'?course_id': COURSE_KEY
});
// Trigger auth complete on the register view
view.subview.register.trigger('auth-complete'); view.subview.register.trigger('auth-complete');
// Expect that the view tried to add the course to the user's shopping cart // Since we did not provide a ?next query param, expect a redirect to the dashboard.
expect( ShoppingCartInterface.addCourseToCart ).toHaveBeenCalledWith( COURSE_KEY ); expect( view.redirect ).toHaveBeenCalledWith( '/dashboard' );
}); });
it('redirects the user to the dashboard on auth complete', function() { it('proceeds with the third party auth pipeline if active', function() {
ajaxSpyAndInitialize(this, 'register'); ajaxSpyAndInitialize(this, 'register', '/', THIRD_PARTY_COMPLETE_URL);
// Trigger auth complete // Trigger auth complete
view.subview.register.trigger('auth-complete'); view.subview.register.trigger('auth-complete');
// Since we did not provide a ?next query param, expect a redirect to the dashboard. // Verify that we were redirected
expect( view.redirect ).toHaveBeenCalledWith( '/dashboard' ); expect( view.redirect ).toHaveBeenCalledWith( THIRD_PARTY_COMPLETE_URL );
}); });
it('redirects the user to the next page on auth complete', function() { it('redirects the user to the next page on auth complete', function() {
ajaxSpyAndInitialize(this, 'register'); // The 'next' argument is often used to redirect to the auto-enrollment view
ajaxSpyAndInitialize(this, 'register', FORWARD_URL);
// Simulate providing a ?next query string parameter
setFakeQueryParams({ '?next': FORWARD_URL });
// Trigger auth complete // Trigger auth complete
view.subview.register.trigger('auth-complete'); view.subview.register.trigger('auth-complete');
...@@ -257,11 +180,7 @@ define([ ...@@ -257,11 +180,7 @@ define([
}); });
it('ignores redirect to external URLs', function() { it('ignores redirect to external URLs', function() {
ajaxSpyAndInitialize(this, 'register'); ajaxSpyAndInitialize(this, 'register', "http://www.example.com");
// Simulate providing a ?next query string parameter
// that goes to an external URL
setFakeQueryParams({ '?next': "http://www.example.com" });
// Trigger auth complete // Trigger auth complete
view.subview.register.trigger('auth-complete'); view.subview.register.trigger('auth-complete');
......
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 || {}; ...@@ -11,6 +11,7 @@ var edx = edx || {};
return new edx.student.account.AccessView({ return new edx.student.account.AccessView({
mode: container.data('initial-mode'), mode: container.data('initial-mode'),
thirdPartyAuth: container.data('third-party-auth'), thirdPartyAuth: container.data('third-party-auth'),
nextUrl: container.data('next-url'),
platformName: container.data('platform-name'), platformName: container.data('platform-name'),
loginFormDesc: container.data('login-form-desc'), loginFormDesc: container.data('login-form-desc'),
registrationFormDesc: container.data('registration-form-desc'), registrationFormDesc: container.data('registration-form-desc'),
......
...@@ -22,13 +22,12 @@ var edx = edx || {}; ...@@ -22,13 +22,12 @@ var edx = edx || {};
* @param {string} courseKey Slash-separated course key. * @param {string} courseKey Slash-separated course key.
* @param {string} emailOptIn The preference to opt in or out of organization emails. * @param {string} emailOptIn The preference to opt in or out of organization emails.
*/ */
setPreference: function( courseKey, emailOptIn, context ) { setPreference: function( courseKey, emailOptIn ) {
return $.ajax({ return $.ajax({
url: this.urls.emailOptInUrl, url: this.urls.emailOptInUrl,
type: 'POST', type: 'POST',
data: {course_id: courseKey, email_opt_in: emailOptIn}, data: {course_id: courseKey, email_opt_in: emailOptIn},
headers: this.headers, headers: this.headers
context: context
}); });
} }
}; };
......
var edx = edx || {}; var edx = edx || {};
(function($, _, _s, Backbone, gettext) { (function($, _, _s, Backbone, History) {
'use strict'; 'use strict';
edx.student = edx.student || {}; edx.student = edx.student || {};
edx.student.account = edx.student.account || {}; 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({ edx.student.account.AccessView = Backbone.View.extend({
el: '#login-and-registration-container', el: '#login-and-registration-container',
...@@ -29,11 +21,7 @@ var edx = edx || {}; ...@@ -29,11 +21,7 @@ var edx = edx || {};
passwordHelp: {} passwordHelp: {}
}, },
urls: { nextUrl: '/dashboard',
dashboard: '/dashboard',
payment: '/verify_student/start-flow/',
trackSelection: '/course_modes/choose/'
},
// The form currently loaded // The form currently loaded
activeForm: '', activeForm: '',
...@@ -54,6 +42,13 @@ var edx = edx || {}; ...@@ -54,6 +42,13 @@ var edx = edx || {};
providers: [] 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 = { this.formDescriptions = {
login: obj.loginFormDesc, login: obj.loginFormDesc,
register: obj.registrationFormDesc, register: obj.registrationFormDesc,
...@@ -69,6 +64,10 @@ var edx = edx || {}; ...@@ -69,6 +64,10 @@ var edx = edx || {};
}); });
this.render(); 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() { render: function() {
...@@ -199,113 +198,18 @@ var edx = edx || {}; ...@@ -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. * If we're in a third party auth pipeline, we must complete the pipeline.
* - Update email opt-in preferences * Otherwise, redirect to the specified next step.
*
* These actions are delegated from the authComplete function to additional
* functions requiring authentication.
* *
*/ */
authComplete: function() { authComplete: function() {
var emailOptIn = edx.student.account.EmailOptInInterface, if (this.thirdPartyAuth && this.thirdPartyAuth.finishAuthUrl) {
queryParams = this.queryParams(); this.redirect(this.thirdPartyAuth.finishAuthUrl);
// Note: the third party auth URL likely contains another redirect URL embedded inside
// Set the email opt in preference.
if (!_.isUndefined(queryParams.emailOptIn) && queryParams.enrollmentAction) {
emailOptIn.setPreference(
decodeURIComponent(queryParams.courseId),
queryParams.emailOptIn,
this
).always(this.enrollment);
} 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 { } else {
/* this.redirect(this.nextUrl);
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 );
} }
}, },
...@@ -314,25 +218,7 @@ var edx = edx || {}; ...@@ -314,25 +218,7 @@ var edx = edx || {};
* @param {string} url The URL to redirect to. * @param {string} url The URL to redirect to.
*/ */
redirect: function( url ) { redirect: function( url ) {
window.location.href = url; window.location.replace(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')
};
}, },
form: { form: {
...@@ -361,4 +247,4 @@ var edx = edx || {}; ...@@ -361,4 +247,4 @@ var edx = edx || {};
} }
} }
}); });
})(jQuery, _, _.str, Backbone, gettext); })(jQuery, _, _.str, Backbone, History);
/**
* 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 || {}; ...@@ -26,6 +26,7 @@ var edx = edx || {};
preRender: function( data ) { preRender: function( data ) {
this.providers = data.thirdPartyAuth.providers || []; this.providers = data.thirdPartyAuth.providers || [];
this.currentProvider = data.thirdPartyAuth.currentProvider || ''; this.currentProvider = data.thirdPartyAuth.currentProvider || '';
this.errorMessage = data.thirdPartyAuth.errorMessage || '';
this.platformName = data.platformName; this.platformName = data.platformName;
this.resetModel = data.resetModel; this.resetModel = data.resetModel;
...@@ -42,6 +43,7 @@ var edx = edx || {}; ...@@ -42,6 +43,7 @@ var edx = edx || {};
context: { context: {
fields: fields, fields: fields,
currentProvider: this.currentProvider, currentProvider: this.currentProvider,
errorMessage: this.errorMessage,
providers: this.providers, providers: this.providers,
platformName: this.platformName platformName: this.platformName
} }
......
...@@ -23,6 +23,7 @@ var edx = edx || {}; ...@@ -23,6 +23,7 @@ var edx = edx || {};
preRender: function( data ) { preRender: function( data ) {
this.providers = data.thirdPartyAuth.providers || []; this.providers = data.thirdPartyAuth.providers || [];
this.currentProvider = data.thirdPartyAuth.currentProvider || ''; this.currentProvider = data.thirdPartyAuth.currentProvider || '';
this.errorMessage = data.thirdPartyAuth.errorMessage || '';
this.platformName = data.platformName; this.platformName = data.platformName;
this.listenTo( this.model, 'sync', this.saveSuccess ); this.listenTo( this.model, 'sync', this.saveSuccess );
...@@ -38,6 +39,7 @@ var edx = edx || {}; ...@@ -38,6 +39,7 @@ var edx = edx || {};
context: { context: {
fields: fields, fields: fields,
currentProvider: this.currentProvider, currentProvider: this.currentProvider,
errorMessage: this.errorMessage,
providers: this.providers, providers: this.providers,
platformName: this.platformName platformName: this.platformName
} }
......
...@@ -515,3 +515,26 @@ $sm-btn-linkedin: #0077b5; ...@@ -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 ...@@ -65,20 +65,17 @@ from microsite_configuration import microsite
$('#login-form').on('ajax:success', function(event, json, xhr) { $('#login-form').on('ajax:success', function(event, json, xhr) {
if(json.success) { if(json.success) {
var u=decodeURI(window.location.search); var nextUrl = "${login_redirect_url}";
var next = u.split("next=")[1]; if (json.redirect_url) {
if (next != undefined) { nextUrl = json.redirect_url; // Most likely third party auth completion. This trumps 'nextUrl' above.
// if next is undefined, decodeURI returns "undefined" causing a bad redirect.
next = decodeURIComponent(next);
} }
if (next && !isExternal(next)) { if (!isExternal(nextUrl)) {
location.href=next; location.href=nextUrl;
} else if(json.redirect_url){
location.href=json.redirect_url;
} else { } else {
location.href="${reverse('dashboard')}"; location.href="${reverse('dashboard')}";
} }
} else if(json.hasOwnProperty('redirect')) { } else if(json.hasOwnProperty('redirect')) {
// Shibboleth authentication redirect requested by the server:
var u=decodeURI(window.location.search); var u=decodeURI(window.location.search);
if (!isExternal(json.redirect)) { // a paranoid check. Our server is the one providing json.redirect if (!isExternal(json.redirect)) { // a paranoid check. Our server is the one providing json.redirect
location.href=json.redirect+u; location.href=json.redirect+u;
...@@ -162,6 +159,15 @@ from microsite_configuration import microsite ...@@ -162,6 +159,15 @@ from microsite_configuration import microsite
<p class="instructions"> </p> <p class="instructions"> </p>
</div> </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"> <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)} ${_('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> </p>
...@@ -196,15 +202,6 @@ from microsite_configuration import microsite ...@@ -196,15 +202,6 @@ from microsite_configuration import microsite
</ol> </ol>
</div> </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"> <div class="form-actions">
<button name="submit" type="submit" id="submit" class="action action-primary action-update login-button"></button> <button name="submit" type="submit" id="submit" class="action action-primary action-update login-button"></button>
</div> </div>
......
...@@ -171,18 +171,7 @@ from branding import api as branding_api ...@@ -171,18 +171,7 @@ from branding import api as branding_api
</html> </html>
<%def name="login_query()">${ <%def name="login_query()">${
u"?course_id={0}&enrollment_action={1}{course_mode}{email_opt_in}".format( u"?next={0}".format(urlquote_plus(login_redirect_url)) if login_redirect_url else ""
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 ""
}</%def> }</%def>
<!-- Performance beacon for onload times --> <!-- Performance beacon for onload times -->
......
...@@ -130,7 +130,7 @@ site_status_msg = get_site_status_msg(course_id) ...@@ -130,7 +130,7 @@ site_status_msg = get_site_status_msg(course_id)
</li> </li>
% else: % else:
<li class="nav-global-04"> <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> </li>
% endif % endif
% endif % endif
......
...@@ -54,8 +54,15 @@ import calendar ...@@ -54,8 +54,15 @@ import calendar
}); });
$('#register-form').on('ajax:success', function(event, json, xhr) { $('#register-form').on('ajax:success', function(event, json, xhr) {
var url = json.redirect_url || "${reverse('dashboard')}"; var nextUrl = "${login_redirect_url}";
location.href = 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) { $('#register-form').on('ajax:error', function(event, jqXHR, textStatus) {
...@@ -359,15 +366,6 @@ import calendar ...@@ -359,15 +366,6 @@ import calendar
</ol> </ol>
</div> </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"> <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> <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> </div>
......
<%! 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"/>
<div class="status already-authenticated-msg hidden"> <div class="status already-authenticated-msg hidden">
<% if (context.currentProvider) { %> <% if (context.currentProvider) { %>
<p class="message-copy"> <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> </p>
<% } %> <% } %>
</div> </div>
...@@ -20,6 +20,13 @@ ...@@ -20,6 +20,13 @@
<ul class="message-copy"></ul> <ul class="message-copy"></ul>
</div> </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"> <form id="login" class="login-form" tabindex="-1">
<div class="section-title lines"> <div class="section-title lines">
......
...@@ -27,6 +27,7 @@ ...@@ -27,6 +27,7 @@
class="login-register" class="login-register"
data-initial-mode="${initial_mode}" data-initial-mode="${initial_mode}"
data-third-party-auth='${third_party_auth|h}' data-third-party-auth='${third_party_auth|h}'
data-next-url='${login_redirect_url|h}'
data-platform-name='${platform_name}' data-platform-name='${platform_name}'
data-login-form-desc='${login_form_desc|h}' data-login-form-desc='${login_form_desc|h}'
data-registration-form-desc='${registration_form_desc|h}' data-registration-form-desc='${registration_form_desc|h}'
......
...@@ -4,6 +4,14 @@ ...@@ -4,6 +4,14 @@
</div> </div>
<form id="register" class="register-form" autocomplete="off" tabindex="-1"> <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) { %> <% if (context.currentProvider) { %>
<div class="status" aria-hidden="false"> <div class="status" aria-hidden="false">
<p class="message-copy"> <p class="message-copy">
......
...@@ -69,7 +69,7 @@ pyparsing==2.0.1 ...@@ -69,7 +69,7 @@ pyparsing==2.0.1
python-memcached==1.48 python-memcached==1.48
python-openid==2.2.5 python-openid==2.2.5
python-dateutil==2.1 python-dateutil==2.1
python-social-auth==0.1.23 python-social-auth==0.2.7
pytz==2015.2 pytz==2015.2
pysrt==0.4.7 pysrt==0.4.7
PyYAML==3.10 PyYAML==3.10
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment