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}
......
...@@ -21,13 +21,11 @@ THIRD_PARTY_AUTH_BACKENDS = ["google-oauth2", "facebook"] ...@@ -21,13 +21,11 @@ THIRD_PARTY_AUTH_BACKENDS = ["google-oauth2", "facebook"]
THIRD_PARTY_AUTH_PROVIDERS = ["Google", "Facebook"] THIRD_PARTY_AUTH_PROVIDERS = ["Google", "Facebook"]
def _third_party_login_url(backend_name, auth_entry, course_id=None, redirect_url=None): def _third_party_login_url(backend_name, auth_entry, redirect_url=None):
"""Construct the login URL to start third party authentication. """ """Construct the login URL to start third party authentication. """
params = [("auth_entry", auth_entry)] params = [("auth_entry", auth_entry)]
if redirect_url: if redirect_url:
params.append(("next", redirect_url)) params.append(("next", redirect_url))
if course_id:
params.append(("enroll_course_id", course_id))
return u"{url}?{params}".format( return u"{url}?{params}".format(
url=reverse("social:begin", kwargs={"backend": backend_name}), url=reverse("social:begin", kwargs={"backend": backend_name}),
...@@ -35,6 +33,11 @@ def _third_party_login_url(backend_name, auth_entry, course_id=None, redirect_ur ...@@ -35,6 +33,11 @@ def _third_party_login_url(backend_name, auth_entry, course_id=None, redirect_ur
) )
def _finish_auth_url(params):
""" Construct the URL that follows login/registration if we are doing auto-enrollment """
return u"{}?{}".format(reverse('finish_auth'), urllib.urlencode(params))
@ddt.ddt @ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class LoginFormTest(UrlResetMixin, ModuleStoreTestCase): class LoginFormTest(UrlResetMixin, ModuleStoreTestCase):
...@@ -46,7 +49,6 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase): ...@@ -46,7 +49,6 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase):
self.url = reverse("signin_user") self.url = reverse("signin_user")
self.course = CourseFactory.create() self.course = CourseFactory.create()
self.course_id = unicode(self.course.id) self.course_id = unicode(self.course.id)
self.course_modes_url = reverse("course_modes_choose", kwargs={"course_id": self.course_id})
self.courseware_url = reverse("courseware", args=[self.course_id]) self.courseware_url = reverse("courseware", args=[self.course_id])
@patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False}) @patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False})
...@@ -65,7 +67,8 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase): ...@@ -65,7 +67,8 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase):
def test_third_party_auth_with_course_id(self, backend_name): def test_third_party_auth_with_course_id(self, backend_name):
# Provide a course ID to the login page, simulating what happens # Provide a course ID to the login page, simulating what happens
# when a user tries to enroll in a course without being logged in # when a user tries to enroll in a course without being logged in
response = self.client.get(self.url, {"course_id": self.course_id}) params = [('course_id', self.course_id)]
response = self.client.get(self.url, params)
# Expect that the course ID is added to the third party auth entry # Expect that the course ID is added to the third party auth entry
# point, so that the pipeline will enroll the student and # point, so that the pipeline will enroll the student and
...@@ -73,35 +76,12 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase): ...@@ -73,35 +76,12 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase):
expected_url = _third_party_login_url( expected_url = _third_party_login_url(
backend_name, backend_name,
"login", "login",
course_id=self.course_id, redirect_url=_finish_auth_url(params),
redirect_url=self.course_modes_url
)
self.assertContains(response, expected_url)
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
def test_third_party_auth_with_white_label_course(self, backend_name):
# Set the course mode to honor with a min price,
# indicating that the course is behind a paywall.
CourseModeFactory.create(
course_id=self.course.id,
mode_slug="honor",
mode_display_name="Honor",
min_price=100
)
# Expect that we're redirected to the shopping cart
# instead of to the track selection page.
response = self.client.get(self.url, {"course_id": self.course_id})
expected_url = _third_party_login_url(
backend_name,
"login",
course_id=self.course_id,
redirect_url=reverse("shoppingcart.views.show_cart")
) )
self.assertContains(response, expected_url) self.assertContains(response, expected_url)
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS) @ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
def test_third_party_auth_with_redirect_url(self, backend_name): def test_courseware_redirect(self, backend_name):
# Try to access courseware while logged out, expecting to be # Try to access courseware while logged out, expecting to be
# redirected to the login page. # redirected to the login page.
response = self.client.get(self.courseware_url, follow=True) response = self.client.get(self.courseware_url, follow=True)
...@@ -123,28 +103,47 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase): ...@@ -123,28 +103,47 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase):
) )
self.assertContains(response, expected_url) self.assertContains(response, expected_url)
@ddt.data(None, "true", "false") @ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
def test_email_opt_in(self, opt_in_value): def test_third_party_auth_with_params(self, backend_name):
params = { params = [
'course_id': self.course_id, ('course_id', self.course_id),
'enrollment_action': 'enroll' ('enrollment_action', 'enroll'),
} ('course_mode', 'honor'),
('email_opt_in', 'true'),
('next', '/custom/final/destination'),
]
response = self.client.get(self.url, params)
expected_url = _third_party_login_url(
backend_name,
"login",
redirect_url=_finish_auth_url(params),
)
self.assertContains(response, expected_url)
if opt_in_value is not None: @ddt.data(None, "true", "false")
params['email_opt_in'] = opt_in_value def test_params(self, opt_in_value):
params = [
('course_id', self.course_id),
('enrollment_action', 'enroll'),
('course_mode', 'honor'),
('email_opt_in', opt_in_value),
('next', '/custom/final/destination'),
]
# Get the login page # Get the login page
response = self.client.get(self.url, params) response = self.client.get(self.url, params)
# Verify that the hidden parameter is set correctly # Verify that the parameters are sent on to the next page correctly
hidden_param = '<input type="hidden" name="email_opt_in" value="{val}"'.format( post_login_handler = _finish_auth_url(params)
val=opt_in_value js_success_var = 'var nextUrl = "{}";'.format(post_login_handler)
) self.assertContains(response, js_success_var)
if opt_in_value is not None: # Verify that the login link preserves the querystring params
self.assertContains(response, hidden_param) login_link = u"{url}?{params}".format(
else: url=reverse('signin_user'),
self.assertNotContains(response, hidden_param) params=urllib.urlencode([('next', post_login_handler)])
)
self.assertContains(response, login_link)
@ddt.ddt @ddt.ddt
...@@ -158,7 +157,6 @@ class RegisterFormTest(UrlResetMixin, ModuleStoreTestCase): ...@@ -158,7 +157,6 @@ class RegisterFormTest(UrlResetMixin, ModuleStoreTestCase):
self.url = reverse("register_user") self.url = reverse("register_user")
self.course = CourseFactory.create() self.course = CourseFactory.create()
self.course_id = unicode(self.course.id) self.course_id = unicode(self.course.id)
self.course_modes_url = reverse("course_modes_choose", kwargs={"course_id": self.course_id})
@patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False}) @patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False})
@ddt.data(*THIRD_PARTY_AUTH_PROVIDERS) @ddt.data(*THIRD_PARTY_AUTH_PROVIDERS)
...@@ -173,63 +171,43 @@ class RegisterFormTest(UrlResetMixin, ModuleStoreTestCase): ...@@ -173,63 +171,43 @@ class RegisterFormTest(UrlResetMixin, ModuleStoreTestCase):
self.assertContains(response, expected_url) self.assertContains(response, expected_url)
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS) @ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
def test_register_third_party_auth_with_course_id(self, backend_name): def test_register_third_party_auth_with_params(self, backend_name):
response = self.client.get(self.url, {"course_id": self.course_id}) params = [
expected_url = _third_party_login_url( ('course_id', self.course_id),
backend_name, ('enrollment_action', 'enroll'),
"register", ('course_mode', 'honor'),
course_id=self.course_id, ('email_opt_in', 'true'),
redirect_url=self.course_modes_url ('next', '/custom/final/destination'),
) ]
self.assertContains(response, expected_url) response = self.client.get(self.url, params)
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
def test_third_party_auth_with_white_label_course(self, backend_name):
# Set the course mode to honor with a min price,
# indicating that the course is behind a paywall.
CourseModeFactory.create(
course_id=self.course.id,
mode_slug="honor",
mode_display_name="Honor",
min_price=100
)
# Expect that we're redirected to the shopping cart
# instead of to the track selection page.
response = self.client.get(self.url, {"course_id": self.course_id})
expected_url = _third_party_login_url( expected_url = _third_party_login_url(
backend_name, backend_name,
"register", "register",
course_id=self.course_id, redirect_url=_finish_auth_url(params),
redirect_url=reverse("shoppingcart.views.show_cart")
) )
self.assertContains(response, expected_url) self.assertContains(response, expected_url)
@ddt.data(None, "true", "false") @ddt.data(None, "true", "false")
def test_email_opt_in(self, opt_in_value): def test_params(self, opt_in_value):
params = OrderedDict() params = [
params['course_id'] = self.course_id ('course_id', self.course_id),
params['enrollment_action'] = 'enroll' ('enrollment_action', 'enroll'),
('course_mode', 'honor'),
if opt_in_value is not None: ('email_opt_in', opt_in_value),
params['email_opt_in'] = opt_in_value ('next', '/custom/final/destination'),
]
# Get the login page # Get the login page
response = self.client.get(self.url, params) response = self.client.get(self.url, params)
# Verify that the hidden parameter is set correctly # Verify that the parameters are sent on to the next page correctly
hidden_param = '<input type="hidden" name="email_opt_in" value="{val}"'.format( post_login_handler = _finish_auth_url(params)
val=opt_in_value js_success_var = 'var nextUrl = "{}";'.format(post_login_handler)
) self.assertContains(response, js_success_var)
if opt_in_value is not None:
self.assertContains(response, hidden_param)
else:
self.assertNotContains(response, hidden_param)
# Verify that the login link preserves the querystring params # Verify that the login link preserves the querystring params
login_link = u"{url}?{params}".format( login_link = u"{url}?{params}".format(
url=reverse('signin_user'), url=reverse('signin_user'),
params=urllib.urlencode(params) params=urllib.urlencode([('next', post_login_handler)])
) )
self.assertContains(response, login_link) self.assertContains(response, login_link)
...@@ -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
......
...@@ -106,8 +106,8 @@ from util.password_policy_validators import ( ...@@ -106,8 +106,8 @@ from util.password_policy_validators import (
import third_party_auth import third_party_auth
from third_party_auth import pipeline, provider from third_party_auth import pipeline, provider
from student.helpers import ( from student.helpers import (
auth_pipeline_urls, set_logged_in_cookie, set_logged_in_cookie, check_verify_status_by_course,
check_verify_status_by_course auth_pipeline_urls, get_next_url_for_login_page
) )
from student.models import anonymous_id_for_user from student.models import anonymous_id_for_user
from xmodule.error_module import ErrorDescriptor from xmodule.error_module import ErrorDescriptor
...@@ -356,24 +356,30 @@ def signin_user(request): ...@@ -356,24 +356,30 @@ def signin_user(request):
external_auth_response = external_auth_login(request) external_auth_response = external_auth_login(request)
if external_auth_response is not None: if external_auth_response is not None:
return external_auth_response return external_auth_response
# Determine the URL to redirect to following login:
redirect_to = get_next_url_for_login_page(request)
if request.user.is_authenticated(): if request.user.is_authenticated():
return redirect(reverse('dashboard')) return redirect(redirect_to)
third_party_auth_error = None
for msg in messages.get_messages(request):
if msg.extra_tags.split()[0] == "social-auth":
# msg may or may not be translated. Try translating [again] in case we are able to:
third_party_auth_error = _(msg) # pylint: disable=translation-of-non-string
break
course_id = request.GET.get('course_id')
email_opt_in = request.GET.get('email_opt_in')
context = { context = {
'course_id': course_id, 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header
'email_opt_in': email_opt_in,
'enrollment_action': request.GET.get('enrollment_action'),
# Bool injected into JS to submit form if we're inside a running third- # Bool injected into JS to submit form if we're inside a running third-
# party auth pipeline; distinct from the actual instance of the running # party auth pipeline; distinct from the actual instance of the running
# pipeline, if any. # pipeline, if any.
'pipeline_running': 'true' if pipeline.running(request) else 'false', 'pipeline_running': 'true' if pipeline.running(request) else 'false',
'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, course_id=course_id, email_opt_in=email_opt_in), 'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to),
'platform_name': microsite.get_value( 'platform_name': microsite.get_value(
'platform_name', 'platform_name',
settings.PLATFORM_NAME settings.PLATFORM_NAME
), ),
'third_party_auth_error': third_party_auth_error
} }
return render_to_response('login.html', context) return render_to_response('login.html', context)
...@@ -382,24 +388,21 @@ def signin_user(request): ...@@ -382,24 +388,21 @@ def signin_user(request):
@ensure_csrf_cookie @ensure_csrf_cookie
def register_user(request, extra_context=None): def register_user(request, extra_context=None):
"""Deprecated. To be replaced by :class:`student_account.views.login_and_registration_form`.""" """Deprecated. To be replaced by :class:`student_account.views.login_and_registration_form`."""
# Determine the URL to redirect to following login:
redirect_to = get_next_url_for_login_page(request)
if request.user.is_authenticated(): if request.user.is_authenticated():
return redirect(reverse('dashboard')) return redirect(redirect_to)
external_auth_response = external_auth_register(request) external_auth_response = external_auth_register(request)
if external_auth_response is not None: if external_auth_response is not None:
return external_auth_response return external_auth_response
course_id = request.GET.get('course_id')
email_opt_in = request.GET.get('email_opt_in')
context = { context = {
'course_id': course_id, 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header
'email_opt_in': email_opt_in,
'email': '', 'email': '',
'enrollment_action': request.GET.get('enrollment_action'),
'name': '', 'name': '',
'running_pipeline': None, 'running_pipeline': None,
'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, course_id=course_id, email_opt_in=email_opt_in), 'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, redirect_url=redirect_to),
'platform_name': microsite.get_value( 'platform_name': microsite.get_value(
'platform_name', 'platform_name',
settings.PLATFORM_NAME settings.PLATFORM_NAME
...@@ -729,33 +732,6 @@ def _allow_donation(course_modes, course_id, enrollment): ...@@ -729,33 +732,6 @@ def _allow_donation(course_modes, course_id, enrollment):
return donations_enabled and enrollment.mode in course_modes[course_id] and course_modes[course_id][enrollment.mode].min_price == 0 return donations_enabled and enrollment.mode in course_modes[course_id] and course_modes[course_id][enrollment.mode].min_price == 0
def try_change_enrollment(request):
"""
This method calls change_enrollment if the necessary POST
parameters are present, but does not return anything in most cases. It
simply logs the result or exception. This is usually
called after a registration or login, as secondary action.
It should not interrupt a successful registration or login.
"""
if 'enrollment_action' in request.POST:
try:
enrollment_response = change_enrollment(request)
# There isn't really a way to display the results to the user, so we just log it
# We expect the enrollment to be a success, and will show up on the dashboard anyway
log.info(
u"Attempted to automatically enroll after login. Response code: %s; response body: %s",
enrollment_response.status_code,
enrollment_response.content
)
# Hack: since change_enrollment delivers its redirect_url in the content
# of its response, we check here that only the 200 codes with content
# will return redirect_urls.
if enrollment_response.status_code == 200 and enrollment_response.content != '':
return enrollment_response.content
except Exception as exc: # pylint: disable=broad-except
log.exception(u"Exception automatically enrolling after login: %s", exc)
def _update_email_opt_in(request, org): def _update_email_opt_in(request, org):
"""Helper function used to hit the profile API if email opt-in is enabled.""" """Helper function used to hit the profile API if email opt-in is enabled."""
...@@ -780,9 +756,8 @@ def change_enrollment(request, check_access=True): ...@@ -780,9 +756,8 @@ def change_enrollment(request, check_access=True):
course, a 400 error will be returned. If the user is not logged in, 403 course, a 400 error will be returned. If the user is not logged in, 403
will be returned; it is important that only this case return 403 so the will be returned; it is important that only this case return 403 so the
front end can redirect the user to a registration or login page when this front end can redirect the user to a registration or login page when this
happens. This function should only be called from an AJAX request or happens. This function should only be called from an AJAX request, so
as a post-login/registration helper, so the error messages in the responses the error messages in the responses should never actually be user-visible.
should never actually be user-visible.
Args: Args:
request (`Request`): The Django request object request (`Request`): The Django request object
...@@ -874,20 +849,6 @@ def change_enrollment(request, check_access=True): ...@@ -874,20 +849,6 @@ def change_enrollment(request, check_access=True):
# Otherwise, there is only one mode available (the default) # Otherwise, there is only one mode available (the default)
return HttpResponse() return HttpResponse()
elif action == "add_to_cart":
# Pass the request handling to shoppingcart.views
# The view in shoppingcart.views performs error handling and logs different errors. But this elif clause
# is only used in the "auto-add after user reg/login" case, i.e. it's always wrapped in try_change_enrollment.
# This means there's no good way to display error messages to the user. So we log the errors and send
# the user to the shopping cart page always, where they can reasonably discern the status of their cart,
# whether things got added, etc
shoppingcart.views.add_course_to_cart(request, course_id.to_deprecated_string())
return HttpResponse(
reverse("shoppingcart.views.show_cart")
)
elif action == "unenroll": elif action == "unenroll":
if not CourseEnrollment.is_enrolled(user, course_id): if not CourseEnrollment.is_enrolled(user, course_id):
return HttpResponseBadRequest(_("You are not enrolled in this course")) return HttpResponseBadRequest(_("You are not enrolled in this course"))
...@@ -905,8 +866,9 @@ def accounts_login(request): ...@@ -905,8 +866,9 @@ def accounts_login(request):
if external_auth_response is not None: if external_auth_response is not None:
return external_auth_response return external_auth_response
redirect_to = request.GET.get('next') redirect_to = get_next_url_for_login_page(request)
context = { context = {
'login_redirect_url': redirect_to,
'pipeline_running': 'false', 'pipeline_running': 'false',
'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to), 'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to),
'platform_name': settings.PLATFORM_NAME, 'platform_name': settings.PLATFORM_NAME,
...@@ -1091,8 +1053,7 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un ...@@ -1091,8 +1053,7 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
log.exception(exc) log.exception(exc)
raise raise
redirect_url = try_change_enrollment(request) redirect_url = None # The AJAX method calling should know the default destination upon success
if third_party_auth_successful: if third_party_auth_successful:
redirect_url = pipeline.get_complete_url(backend_name) redirect_url = pipeline.get_complete_url(backend_name)
...@@ -1129,7 +1090,7 @@ def login_oauth_token(request, backend): ...@@ -1129,7 +1090,7 @@ def login_oauth_token(request, backend):
""" """
warnings.warn("Please use AccessTokenExchangeView instead.", DeprecationWarning) warnings.warn("Please use AccessTokenExchangeView instead.", DeprecationWarning)
backend = request.social_strategy.backend backend = request.backend
if isinstance(backend, social_oauth.BaseOAuth1) or isinstance(backend, social_oauth.BaseOAuth2): if isinstance(backend, social_oauth.BaseOAuth1) or isinstance(backend, social_oauth.BaseOAuth2):
if "access_token" in request.POST: if "access_token" in request.POST:
# Tell third party auth pipeline that this is an API call # Tell third party auth pipeline that this is an API call
...@@ -1137,7 +1098,7 @@ def login_oauth_token(request, backend): ...@@ -1137,7 +1098,7 @@ def login_oauth_token(request, backend):
user = None user = None
try: try:
user = backend.do_auth(request.POST["access_token"]) user = backend.do_auth(request.POST["access_token"])
except HTTPError: except (HTTPError, AuthException):
pass pass
# do_auth can return a non-User object if it fails # do_auth can return a non-User object if it fails
if user and isinstance(user, User): if user and isinstance(user, User):
...@@ -1445,9 +1406,13 @@ def create_account_with_params(request, params): ...@@ -1445,9 +1406,13 @@ def create_account_with_params(request, params):
# first, create the account # first, create the account
(user, profile, registration) = _do_create_account(form) (user, profile, registration) = _do_create_account(form)
# next, link the account with social auth, if provided # next, link the account with social auth, if provided via the API.
# (If the user is using the normal register page, the social auth pipeline does the linking, not this code)
if should_link_with_social_auth: if should_link_with_social_auth:
request.social_strategy = social_utils.load_strategy(backend=params['provider'], request=request) backend_name = params['provider']
request.social_strategy = social_utils.load_strategy(request)
redirect_uri = reverse('social:complete', args=(backend_name, ))
request.backend = social_utils.load_backend(request.social_strategy, backend_name, redirect_uri)
social_access_token = params.get('access_token') social_access_token = params.get('access_token')
if not social_access_token: if not social_access_token:
raise ValidationError({ raise ValidationError({
...@@ -1461,7 +1426,7 @@ def create_account_with_params(request, params): ...@@ -1461,7 +1426,7 @@ def create_account_with_params(request, params):
pipeline_user = None pipeline_user = None
error_message = "" error_message = ""
try: try:
pipeline_user = request.social_strategy.backend.do_auth(social_access_token, user=user) pipeline_user = request.backend.do_auth(social_access_token, user=user)
except AuthAlreadyAssociated: except AuthAlreadyAssociated:
error_message = _("The provided access_token is already associated with another user.") error_message = _("The provided access_token is already associated with another user.")
except (HTTPError, AuthException): except (HTTPError, AuthException):
...@@ -1635,7 +1600,7 @@ def create_account(request, post_override=None): ...@@ -1635,7 +1600,7 @@ def create_account(request, post_override=None):
status=400 status=400
) )
redirect_url = try_change_enrollment(request) redirect_url = None # The AJAX method calling should know the default destination upon success
# Resume the third-party-auth pipeline if necessary. # Resume the third-party-auth pipeline if necessary.
if third_party_auth.is_enabled() and pipeline.running(request): if third_party_auth.is_enabled() and pipeline.running(request):
......
"""
DummyProvider: A fake Third Party Auth provider for testing & development purposes.
"""
from social.backends.base import BaseAuth
from social.exceptions import AuthFailed
from .provider import BaseProvider
class DummyBackend(BaseAuth): # pylint: disable=abstract-method
"""
python-social-auth backend that doesn't actually go to any third party site
"""
name = "dummy"
SUCCEED = True # You can patch this during tests in order to control whether or not login works
def auth_url(self):
""" Get the URL to which we must redirect in order to authenticate the user """
return self.redirect_uri
def get_user_details(self, response):
""" Get user details like full name, email, etc. from the third party """
return {
'fullname': "William Adama",
'first_name': "Bill",
'last_name': "Adama",
'username': "Galactica1",
'email': "adama@fleet.colonies.gov",
}
def get_user_id(self, details, response):
""" Get the permanent ID for this user from the third party. """
return '1234'
def auth_complete(self, *args, **kwargs):
"""
The user has been redirected back from the third party and we should now log them in, if
everything checks out.
"""
if not DummyBackend.SUCCEED:
raise AuthFailed(self, 'Third Party login failed.')
response = {
'dummy': True,
}
kwargs.update({'response': response, 'backend': self})
return self.strategy.authenticate(*args, **kwargs)
class DummyProvider(BaseProvider):
""" Dummy Provider for testing and development """
BACKEND_CLASS = DummyBackend
ICON_CLASS = 'fa-cube'
NAME = 'Dummy'
SETTINGS = {}
...@@ -69,22 +69,13 @@ from django.contrib.auth.models import User ...@@ -69,22 +69,13 @@ from django.contrib.auth.models import User
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import HttpResponseBadRequest from django.http import HttpResponseBadRequest
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils.translation import ugettext as _
from social.apps.django_app.default import models from social.apps.django_app.default import models
from social.exceptions import AuthException from social.exceptions import AuthException
from social.pipeline import partial from social.pipeline import partial
from social.pipeline.social_auth import associate_by_email from social.pipeline.social_auth import associate_by_email
import student import student
from embargo import api as embargo_api
from shoppingcart.models import Order, PaidCourseRegistration # pylint: disable=import-error
from shoppingcart.exceptions import ( # pylint: disable=import-error
CourseDoesNotExistException,
ItemAlreadyInCartException,
AlreadyEnrolledInCourseException
)
from student.models import CourseEnrollment, CourseEnrollmentException
from course_modes.models import CourseMode
from opaque_keys.edx.keys import CourseKey
from logging import getLogger from logging import getLogger
...@@ -103,14 +94,8 @@ from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in ...@@ -103,14 +94,8 @@ from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
# `AUTH_REDIRECT_KEY` provides an optional URL to redirect # `AUTH_REDIRECT_KEY` provides an optional URL to redirect
# to upon successful authentication # to upon successful authentication
# (if not provided, defaults to `_SOCIAL_AUTH_LOGIN_REDIRECT_URL`) # (if not provided, defaults to `_SOCIAL_AUTH_LOGIN_REDIRECT_URL`)
#
# `AUTH_ENROLL_COURSE_ID_KEY` provides the course ID that a student
# is trying to enroll in, used to generate analytics events
# and auto-enroll students.
AUTH_ENTRY_KEY = 'auth_entry' AUTH_ENTRY_KEY = 'auth_entry'
AUTH_REDIRECT_KEY = 'next' AUTH_REDIRECT_KEY = 'next'
AUTH_ENROLL_COURSE_ID_KEY = 'enroll_course_id'
AUTH_EMAIL_OPT_IN_KEY = 'email_opt_in'
# The following are various possible values for the AUTH_ENTRY_KEY. # The following are various possible values for the AUTH_ENTRY_KEY.
...@@ -192,6 +177,19 @@ class AuthEntryError(AuthException): ...@@ -192,6 +177,19 @@ class AuthEntryError(AuthException):
""" """
class NotActivatedException(AuthException):
""" Raised when a user tries to login to an unverified account """
def __init__(self, backend, email):
self.email = email
super(NotActivatedException, self).__init__(backend, email)
def __str__(self):
return (
_('This account has not yet been activated. An activation email has been re-sent to {email_address}.')
.format(email_address=self.email)
)
class ProviderUserState(object): class ProviderUserState(object):
"""Object representing the provider state (attached or not) for a user. """Object representing the provider state (attached or not) for a user.
...@@ -260,7 +258,7 @@ def _get_enabled_provider_by_name(provider_name): ...@@ -260,7 +258,7 @@ def _get_enabled_provider_by_name(provider_name):
return enabled_provider return enabled_provider
def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None, enroll_course_id=None, email_opt_in=None): def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None):
"""Creates a URL to hook into social auth endpoints.""" """Creates a URL to hook into social auth endpoints."""
kwargs = {'backend': backend_name} kwargs = {'backend': backend_name}
url = reverse(view_name, kwargs=kwargs) url = reverse(view_name, kwargs=kwargs)
...@@ -272,12 +270,6 @@ def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None, enroll ...@@ -272,12 +270,6 @@ def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None, enroll
if redirect_url: if redirect_url:
query_params[AUTH_REDIRECT_KEY] = redirect_url query_params[AUTH_REDIRECT_KEY] = redirect_url
if enroll_course_id:
query_params[AUTH_ENROLL_COURSE_ID_KEY] = enroll_course_id
if email_opt_in:
query_params[AUTH_EMAIL_OPT_IN_KEY] = email_opt_in
return u"{url}?{params}".format( return u"{url}?{params}".format(
url=url, url=url,
params=urllib.urlencode(query_params) params=urllib.urlencode(query_params)
...@@ -322,7 +314,7 @@ def get_disconnect_url(provider_name): ...@@ -322,7 +314,7 @@ def get_disconnect_url(provider_name):
return _get_url('social:disconnect', enabled_provider.BACKEND_CLASS.name) return _get_url('social:disconnect', enabled_provider.BACKEND_CLASS.name)
def get_login_url(provider_name, auth_entry, redirect_url=None, enroll_course_id=None, email_opt_in=None): def get_login_url(provider_name, auth_entry, redirect_url=None):
"""Gets the login URL for the endpoint that kicks off auth with a provider. """Gets the login URL for the endpoint that kicks off auth with a provider.
Args: Args:
...@@ -336,14 +328,6 @@ def get_login_url(provider_name, auth_entry, redirect_url=None, enroll_course_id ...@@ -336,14 +328,6 @@ def get_login_url(provider_name, auth_entry, redirect_url=None, enroll_course_id
redirect_url (string): If provided, redirect to this URL at the end redirect_url (string): If provided, redirect to this URL at the end
of the authentication process. of the authentication process.
enroll_course_id (string): If provided, auto-enroll the user in this
course upon successful authentication.
email_opt_in (string): If set to 'true' (case insensitive), user will
be opted into organization-wide email. Any other string will
equate to False, and the user will be opted out of organization-wide
email.
Returns: Returns:
String. URL that starts the auth pipeline for a provider. String. URL that starts the auth pipeline for a provider.
...@@ -357,8 +341,6 @@ def get_login_url(provider_name, auth_entry, redirect_url=None, enroll_course_id ...@@ -357,8 +341,6 @@ def get_login_url(provider_name, auth_entry, redirect_url=None, enroll_course_id
enabled_provider.BACKEND_CLASS.name, enabled_provider.BACKEND_CLASS.name,
auth_entry=auth_entry, auth_entry=auth_entry,
redirect_url=redirect_url, redirect_url=redirect_url,
enroll_course_id=enroll_course_id,
email_opt_in=email_opt_in
) )
...@@ -445,13 +427,44 @@ def parse_query_params(strategy, response, *args, **kwargs): ...@@ -445,13 +427,44 @@ def parse_query_params(strategy, response, *args, **kwargs):
"""Reads whitelisted query params, transforms them into pipeline args.""" """Reads whitelisted query params, transforms them into pipeline args."""
auth_entry = strategy.session.get(AUTH_ENTRY_KEY) auth_entry = strategy.session.get(AUTH_ENTRY_KEY)
if not (auth_entry and auth_entry in _AUTH_ENTRY_CHOICES): if not (auth_entry and auth_entry in _AUTH_ENTRY_CHOICES):
raise AuthEntryError(strategy.backend, 'auth_entry missing or invalid') raise AuthEntryError(strategy.request.backend, 'auth_entry missing or invalid')
return {'auth_entry': auth_entry} return {'auth_entry': auth_entry}
def set_pipeline_timeout(strategy, user, *args, **kwargs):
"""
Set a short session timeout while the pipeline runs, to improve security.
Consider the following attack:
1. Attacker on a public computer visits edX and initiates the third-party login flow
2. Attacker logs into their own third-party account
3. Attacker closes the window and does not complete the login flow
4. Victim on the same computer logs into edX with username/password
5. edX links attacker's third-party account with victim's edX account
6. Attacker logs into victim's edX account using attacker's own third-party account
We have two features of the pipeline designed to prevent this attack:
* This method shortens the Django session timeout during the pipeline. This should mean that
if there is a reasonable delay between steps 3 and 4, the session and pipeline will be
reset, and the attack foiled.
Configure the timeout with the SOCIAL_AUTH_PIPELINE_TIMEOUT setting (Default: 600 seconds)
* On step 4, the login page displays an obvious message to the user, saying "You've
successfully signed into (Google), but your (Google) account isn't linked with an edX
account. To link your accounts, login now using your edX password.".
"""
if strategy.request and not user: # If user is set, we're currently logged in (and/or linked) so it doesn't matter.
strategy.request.session.set_expiry(strategy.setting('PIPELINE_TIMEOUT', 600))
# We don't need to reset this timeout later. Because the user is not logged in and this
# account is not yet linked to an edX account, either the normal 'login' or 'register'
# code must occur during the subsequent ensure_user_information step, and those methods
# will change the session timeout to the "normal" value according to the "Remember Me"
# choice of the user.
@partial.partial @partial.partial
def ensure_user_information(strategy, auth_entry, user=None, *args, **kwargs): def ensure_user_information(strategy, auth_entry, backend=None, user=None, social=None,
allow_inactive_user=False, *args, **kwargs):
""" """
Ensure that we have the necessary information about a user (either an Ensure that we have the necessary information about a user (either an
existing account or registration data) to proceed with the pipeline. existing account or registration data) to proceed with the pipeline.
...@@ -470,63 +483,58 @@ def ensure_user_information(strategy, auth_entry, user=None, *args, **kwargs): ...@@ -470,63 +483,58 @@ def ensure_user_information(strategy, auth_entry, user=None, *args, **kwargs):
# invariants have been violated and future misbehavior is likely. # invariants have been violated and future misbehavior is likely.
def dispatch_to_login(): def dispatch_to_login():
"""Redirects to the login page.""" """Redirects to the login page."""
return redirect(_create_redirect_url(AUTH_DISPATCH_URLS[AUTH_ENTRY_LOGIN], strategy)) return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_LOGIN])
def dispatch_to_register(): def dispatch_to_register():
"""Redirects to the registration page.""" """Redirects to the registration page."""
return redirect(_create_redirect_url(AUTH_DISPATCH_URLS[AUTH_ENTRY_REGISTER], strategy)) return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_REGISTER])
user_inactive = user and not user.is_active
if auth_entry in [AUTH_ENTRY_LOGIN_API, AUTH_ENTRY_REGISTER_API]: if not user:
if not user: if auth_entry in [AUTH_ENTRY_LOGIN_API, AUTH_ENTRY_REGISTER_API]:
return HttpResponseBadRequest() return HttpResponseBadRequest()
elif auth_entry in [AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN_2]:
elif auth_entry in [AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN_2]: # User has authenticated with the third party provider but we don't know which edX
if not user or user_inactive: # account corresponds to them yet, if any.
return dispatch_to_login() return dispatch_to_login()
elif auth_entry in [AUTH_ENTRY_REGISTER, AUTH_ENTRY_REGISTER_2]:
elif auth_entry in [AUTH_ENTRY_REGISTER, AUTH_ENTRY_REGISTER_2]: # User has authenticated with the third party provider and now wants to finish
if not user: # creating their edX account.
return dispatch_to_register() return dispatch_to_register()
elif user_inactive: elif auth_entry == AUTH_ENTRY_ACCOUNT_SETTINGS:
# If the user has a linked account, but has not yet activated raise AuthEntryError(backend, 'auth_entry is wrong. Settings requires a user.')
# we should send them to the login page. The login page else:
# will tell them that they need to activate their account. raise AuthEntryError(backend, 'auth_entry invalid')
return dispatch_to_login()
if not user.is_active:
# The user account has not been verified yet.
def _create_redirect_url(url, strategy): if allow_inactive_user:
""" Given a URL and a Strategy, construct the appropriate redirect URL. # This parameter is used by the auth_exchange app, which always allows users to
# login, whether or not their account is validated.
Construct a redirect URL and append the URL parameters that should be preserved. pass
# IF the user has just registered a new account as part of this pipeline, that is fine
Args: # and we allow the login to continue this once, because if we pause again to force the
url (string): The base URL to use for the redirect. # user to activate their account via email, the pipeline may get lost (e.g. email takes
strategy (Strategy): Used to determine which URL parameters to append to the redirect. # too long to arrive, user opens the activation email on a different device, etc.).
# This is consistent with first party auth and ensures that the pipeline completes
Returns: # fully, which is critical.
A string representation of the URL, with parameters, for redirect. # But if this is an existing account, we refuse to allow them to login again until they
""" # check their email and activate the account.
url_params = {} elif social is not None:
enroll_course_id = strategy.session_get(AUTH_ENROLL_COURSE_ID_KEY) # This third party account is already linked to a user account. That means that the
if enroll_course_id: # user's account existed before this pipeline originally began (since the creation
url_params['course_id'] = enroll_course_id # of the 'social' link entry occurs in one of the following pipeline steps).
url_params['enrollment_action'] = 'enroll' # Reject this login attempt and tell the user to validate their account first.
email_opt_in = strategy.session_get(AUTH_EMAIL_OPT_IN_KEY)
if email_opt_in: # Send them another activation email:
url_params[AUTH_EMAIL_OPT_IN_KEY] = email_opt_in student.views.reactivation_email_for_user(user)
if url_params:
return u'{url}?{params}'.format( raise NotActivatedException(backend, user.email)
url=url, # else: The user must have just successfully registered their account, so we proceed.
params=urllib.urlencode(url_params) # We know they did not just login, because the login process rejects unverified users.
)
else:
return url
@partial.partial @partial.partial
def set_logged_in_cookie(backend=None, user=None, request=None, auth_entry=None, *args, **kwargs): def set_logged_in_cookie(backend=None, user=None, strategy=None, auth_entry=None, *args, **kwargs):
"""This pipeline step sets the "logged in" cookie for authenticated users. """This pipeline step sets the "logged in" cookie for authenticated users.
Some installations have a marketing site front-end separate from Some installations have a marketing site front-end separate from
...@@ -552,6 +560,8 @@ def set_logged_in_cookie(backend=None, user=None, request=None, auth_entry=None, ...@@ -552,6 +560,8 @@ def set_logged_in_cookie(backend=None, user=None, request=None, auth_entry=None,
""" """
if not is_api(auth_entry) and user is not None and user.is_authenticated(): if not is_api(auth_entry) and user is not None and user.is_authenticated():
request = strategy.request if strategy else None
# n.b. for new users, user.is_active may be False at this point; set the cookie anyways.
if request is not None: if request is not None:
# Check that the cookie isn't already set. # Check that the cookie isn't already set.
# This ensures that we allow the user to continue to the next # This ensures that we allow the user to continue to the next
...@@ -587,7 +597,7 @@ def login_analytics(strategy, auth_entry, *args, **kwargs): ...@@ -587,7 +597,7 @@ def login_analytics(strategy, auth_entry, *args, **kwargs):
event_name, event_name,
{ {
'category': "conversion", 'category': "conversion",
'label': strategy.session_get('enroll_course_id'), 'label': None,
'provider': getattr(kwargs['backend'], 'name') 'provider': getattr(kwargs['backend'], 'name')
}, },
context={ context={
...@@ -599,100 +609,7 @@ def login_analytics(strategy, auth_entry, *args, **kwargs): ...@@ -599,100 +609,7 @@ def login_analytics(strategy, auth_entry, *args, **kwargs):
@partial.partial @partial.partial
def change_enrollment(strategy, auth_entry=None, user=None, *args, **kwargs): def associate_by_email_if_login_api(auth_entry, backend, details, user, *args, **kwargs):
"""Enroll a user in a course.
If a user entered the authentication flow when trying to enroll
in a course, then attempt to enroll the user.
We will try to do this if the pipeline was started with the
querystring param `enroll_course_id`.
In the following cases, we can't enroll the user:
* The course does not have an honor mode.
* The course has an honor mode with a minimum price.
* The course is not yet open for enrollment.
* The course does not exist.
If we can't enroll the user now, then skip this step.
For paid courses, users will be redirected to the payment flow
upon completion of the authentication pipeline
(configured using the ?next parameter to the third party auth login url).
Keyword Arguments:
auth_entry: The entry mode into the pipeline.
user (User): The user being authenticated.
"""
# We skip enrollment if the user entered the flow from the "link account"
# button on the account settings page. At this point, either:
#
# 1) The user already had a linked account when they started the enrollment flow,
# in which case they would have been enrolled during the normal authentication process.
#
# 2) The user did NOT have a linked account, in which case they would have
# needed to go through the login/register page. Since we preserve the querystring
# args when sending users to this page, successfully authenticating through this page
# would also enroll the student in the course.
enroll_course_id = strategy.session_get('enroll_course_id')
if enroll_course_id and auth_entry != AUTH_ENTRY_ACCOUNT_SETTINGS:
course_id = CourseKey.from_string(enroll_course_id)
modes = CourseMode.modes_for_course_dict(course_id)
# If the email opt in parameter is found, set the preference.
email_opt_in = strategy.session_get(AUTH_EMAIL_OPT_IN_KEY)
if email_opt_in:
opt_in = email_opt_in.lower() == 'true'
update_email_opt_in(user, course_id.org, opt_in)
# Check whether we're blocked from enrolling by a
# country access rule.
# Note: We skip checking the user's profile setting
# for country here because the "redirect URL" pointing
# to the blocked message page is set when the user
# *enters* the pipeline, at which point they're
# not authenticated. If they end up being blocked
# from the courseware, it's better to let them
# enroll and then show the message when they
# enter the course than to skip enrollment
# altogether.
is_blocked = not embargo_api.check_course_access(
course_id, ip_address=get_ip(strategy.request),
url=strategy.request.path
)
if is_blocked:
# If we're blocked, skip enrollment.
# A redirect URL should have been set so the user
# ends up on the embargo page when enrollment completes.
pass
elif CourseMode.can_auto_enroll(course_id, modes_dict=modes):
try:
CourseEnrollment.enroll(user, course_id, check_access=True)
except CourseEnrollmentException:
pass
except Exception as ex:
logger.exception(ex)
# Handle white-label courses as a special case
# If a course is white-label, we should add it to the shopping cart.
elif CourseMode.is_white_label(course_id, modes_dict=modes):
try:
cart = Order.get_cart_for_user(user)
PaidCourseRegistration.add_to_order(cart, course_id)
except (
CourseDoesNotExistException,
ItemAlreadyInCartException,
AlreadyEnrolledInCourseException,
):
pass
# It's more important to complete login than to
# ensure that the course was added to the shopping cart.
# Log errors, but don't stop the authentication pipeline.
except Exception as ex: # pylint: disable=broad-except
logger.exception(ex)
@partial.partial
def associate_by_email_if_login_api(auth_entry, strategy, details, user, *args, **kwargs):
""" """
This pipeline step associates the current social auth with the user with the This pipeline step associates the current social auth with the user with the
same email address in the database. It defers to the social library's associate_by_email same email address in the database. It defers to the social library's associate_by_email
...@@ -701,7 +618,7 @@ def associate_by_email_if_login_api(auth_entry, strategy, details, user, *args, ...@@ -701,7 +618,7 @@ def associate_by_email_if_login_api(auth_entry, strategy, details, user, *args,
This association is done ONLY if the user entered the pipeline through a LOGIN API. This association is done ONLY if the user entered the pipeline through a LOGIN API.
""" """
if auth_entry == AUTH_ENTRY_LOGIN_API: if auth_entry == AUTH_ENTRY_LOGIN_API:
association_response = associate_by_email(strategy, details, user, *args, **kwargs) association_response = associate_by_email(backend, details, user, *args, **kwargs)
if ( if (
association_response and association_response and
association_response.get('user') and association_response.get('user') and
......
...@@ -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()
......
...@@ -140,7 +140,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -140,7 +140,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
exception_middleware = middleware.ExceptionMiddleware() exception_middleware = middleware.ExceptionMiddleware()
request, _ = self.get_request_and_strategy(auth_entry=auth_entry) request, _ = self.get_request_and_strategy(auth_entry=auth_entry)
response = exception_middleware.process_exception( response = exception_middleware.process_exception(
request, exceptions.AuthCanceled(request.social_strategy.backend)) request, exceptions.AuthCanceled(request.backend))
location = response.get('Location') location = response.get('Location')
self.assertEqual(302, response.status_code) self.assertEqual(302, response.status_code)
...@@ -161,7 +161,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -161,7 +161,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
""" """
_, strategy = self.get_request_and_strategy( _, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
self.create_user_models_for_existing_account( self.create_user_models_for_existing_account(
strategy, email, password, self.get_username(), skip_social_auth=True) strategy, email, password, self.get_username(), skip_social_auth=True)
...@@ -239,7 +239,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -239,7 +239,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
def assert_redirect_to_dashboard_looks_correct(self, response): def assert_redirect_to_dashboard_looks_correct(self, response):
"""Asserts a response would redirect to /dashboard.""" """Asserts a response would redirect to /dashboard."""
self.assertEqual(302, response.status_code) self.assertEqual(302, response.status_code)
# pylint: disable-msg=protected-access # pylint: disable=protected-access
self.assertEqual(auth_settings._SOCIAL_AUTH_LOGIN_REDIRECT_URL, response.get('Location')) self.assertEqual(auth_settings._SOCIAL_AUTH_LOGIN_REDIRECT_URL, response.get('Location'))
def assert_redirect_to_login_looks_correct(self, response): def assert_redirect_to_login_looks_correct(self, response):
...@@ -287,7 +287,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -287,7 +287,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
See student.views.register and student.views._do_create_account. See student.views.register and student.views._do_create_account.
""" """
response_data = self.get_response_data() response_data = self.get_response_data()
uid = strategy.backend.get_user_id(response_data, response_data) uid = strategy.request.backend.get_user_id(response_data, response_data)
user = social_utils.Storage.user.create_user(email=email, password=password, username=username) user = social_utils.Storage.user.create_user(email=email, password=password, username=username)
profile = student_models.UserProfile(user=user) profile = student_models.UserProfile(user=user)
profile.save() profile.save()
...@@ -310,7 +310,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -310,7 +310,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
args = () args = ()
kwargs = { kwargs = {
'request': strategy.request, 'request': strategy.request,
'backend': strategy.backend, 'backend': strategy.request.backend,
'user': None, 'user': None,
'response': self.get_response_data(), 'response': self.get_response_data(),
} }
...@@ -355,8 +355,9 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -355,8 +355,9 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
if auth_entry: if auth_entry:
request.session[pipeline.AUTH_ENTRY_KEY] = auth_entry request.session[pipeline.AUTH_ENTRY_KEY] = auth_entry
strategy = social_utils.load_strategy(backend=self.backend_name, redirect_uri=redirect_uri, request=request) strategy = social_utils.load_strategy(request=request)
request.social_strategy = strategy request.social_strategy = strategy
request.backend = social_utils.load_backend(strategy, self.backend_name, redirect_uri)
return request, strategy return request, strategy
...@@ -404,7 +405,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -404,7 +405,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# configure the backend, and mock out wire traffic. # configure the backend, and mock out wire traffic.
request, strategy = self.get_request_and_strategy( request, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
pipeline.analytics.track = mock.MagicMock() pipeline.analytics.track = mock.MagicMock()
request.user = self.create_user_models_for_existing_account( request.user = self.create_user_models_for_existing_account(
strategy, 'user@example.com', 'password', self.get_username(), skip_social_auth=True) strategy, 'user@example.com', 'password', self.get_username(), skip_social_auth=True)
...@@ -413,12 +414,12 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -413,12 +414,12 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# expected state. # expected state.
self.client.get( self.client.get(
pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN)) pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))
actions.do_complete(strategy, social_views._do_login) # pylint: disable-msg=protected-access actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access
mako_middleware_process_request(strategy.request) mako_middleware_process_request(strategy.request)
student_views.signin_user(strategy.request) student_views.signin_user(strategy.request)
student_views.login_user(strategy.request) student_views.login_user(strategy.request)
actions.do_complete(strategy, social_views._do_login) # pylint: disable-msg=protected-access actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access
# First we expect that we're in the unlinked state, and that there # First we expect that we're in the unlinked state, and that there
# really is no association in the backend. # really is no association in the backend.
...@@ -428,7 +429,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -428,7 +429,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# We should be redirected back to the complete page, setting # We should be redirected back to the complete page, setting
# the "logged in" cookie for the marketing site. # the "logged in" cookie for the marketing site.
self.assert_logged_in_cookie_redirect(actions.do_complete( self.assert_logged_in_cookie_redirect(actions.do_complete(
request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access request.backend, social_views._do_login, request.user, None, # pylint: disable=protected-access
redirect_field_name=auth.REDIRECT_FIELD_NAME redirect_field_name=auth.REDIRECT_FIELD_NAME
)) ))
...@@ -437,7 +438,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -437,7 +438,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# Fire off the auth pipeline to link. # Fire off the auth pipeline to link.
self.assert_redirect_to_dashboard_looks_correct(actions.do_complete( self.assert_redirect_to_dashboard_looks_correct(actions.do_complete(
request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access request.backend, social_views._do_login, request.user, None, # pylint: disable=protected-access
redirect_field_name=auth.REDIRECT_FIELD_NAME)) redirect_field_name=auth.REDIRECT_FIELD_NAME))
# Now we expect to be in the linked state, with a backend entry. # Now we expect to be in the linked state, with a backend entry.
...@@ -449,7 +450,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -449,7 +450,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# configure the backend, and mock out wire traffic. # configure the backend, and mock out wire traffic.
request, strategy = self.get_request_and_strategy( request, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
user = self.create_user_models_for_existing_account( user = self.create_user_models_for_existing_account(
strategy, 'user@example.com', 'password', self.get_username()) strategy, 'user@example.com', 'password', self.get_username())
self.assert_social_auth_exists_for_user(user, strategy) self.assert_social_auth_exists_for_user(user, strategy)
...@@ -461,12 +462,12 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -461,12 +462,12 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# expected state. # expected state.
self.client.get( self.client.get(
pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN)) pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))
actions.do_complete(strategy, social_views._do_login) # pylint: disable-msg=protected-access actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access
mako_middleware_process_request(strategy.request) mako_middleware_process_request(strategy.request)
student_views.signin_user(strategy.request) student_views.signin_user(strategy.request)
student_views.login_user(strategy.request) student_views.login_user(strategy.request)
actions.do_complete(strategy, social_views._do_login, user=user) # pylint: disable-msg=protected-access actions.do_complete(request.backend, social_views._do_login, user=user) # pylint: disable=protected-access
# First we expect that we're in the linked state, with a backend entry. # First we expect that we're in the linked state, with a backend entry.
self.assert_account_settings_context_looks_correct(account_settings_context(request), user, linked=True) self.assert_account_settings_context_looks_correct(account_settings_context(request), user, linked=True)
...@@ -474,7 +475,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -474,7 +475,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# Fire off the disconnect pipeline to unlink. # Fire off the disconnect pipeline to unlink.
self.assert_redirect_to_dashboard_looks_correct(actions.do_disconnect( self.assert_redirect_to_dashboard_looks_correct(actions.do_disconnect(
request.social_strategy, request.user, None, redirect_field_name=auth.REDIRECT_FIELD_NAME)) request.backend, request.user, None, redirect_field_name=auth.REDIRECT_FIELD_NAME))
# Now we expect to be in the unlinked state, with no backend entry. # Now we expect to be in the unlinked state, with no backend entry.
self.assert_account_settings_context_looks_correct(account_settings_context(request), user, linked=False) self.assert_account_settings_context_looks_correct(account_settings_context(request), user, linked=False)
...@@ -490,7 +491,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -490,7 +491,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
username = self.get_username() username = self.get_username()
_, strategy = self.get_request_and_strategy( _, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) backend = strategy.request.backend
backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
linked_user = self.create_user_models_for_existing_account(strategy, email, password, username) linked_user = self.create_user_models_for_existing_account(strategy, email, password, username)
unlinked_user = social_utils.Storage.user.create_user( unlinked_user = social_utils.Storage.user.create_user(
email='other_' + email, password=password, username='other_' + username) email='other_' + email, password=password, username='other_' + username)
...@@ -499,7 +501,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -499,7 +501,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
self.assert_social_auth_does_not_exist_for_user(unlinked_user, strategy) self.assert_social_auth_does_not_exist_for_user(unlinked_user, strategy)
with self.assertRaises(exceptions.AuthAlreadyAssociated): with self.assertRaises(exceptions.AuthAlreadyAssociated):
actions.do_complete(strategy, social_views._do_login, user=unlinked_user) # pylint: disable-msg=protected-access # pylint: disable=protected-access
actions.do_complete(backend, social_views._do_login, user=unlinked_user)
def test_already_associated_exception_populates_dashboard_with_error(self): def test_already_associated_exception_populates_dashboard_with_error(self):
# Instrument the pipeline with an exception. We test that the # Instrument the pipeline with an exception. We test that the
...@@ -511,21 +514,21 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -511,21 +514,21 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# that the duplicate error has no effect on the state of the controls. # that the duplicate error has no effect on the state of the controls.
request, strategy = self.get_request_and_strategy( request, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
user = self.create_user_models_for_existing_account( user = self.create_user_models_for_existing_account(
strategy, 'user@example.com', 'password', self.get_username()) strategy, 'user@example.com', 'password', self.get_username())
self.assert_social_auth_exists_for_user(user, strategy) self.assert_social_auth_exists_for_user(user, strategy)
self.client.get('/login') self.client.get('/login')
self.client.get(pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN)) self.client.get(pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))
actions.do_complete(strategy, social_views._do_login) # pylint: disable-msg=protected-access actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access
mako_middleware_process_request(strategy.request) mako_middleware_process_request(strategy.request)
student_views.signin_user(strategy.request) student_views.signin_user(strategy.request)
student_views.login_user(strategy.request) student_views.login_user(strategy.request)
actions.do_complete(strategy, social_views._do_login, user=user) # pylint: disable-msg=protected-access actions.do_complete(request.backend, social_views._do_login, user=user) # pylint: disable=protected-access
# Monkey-patch storage for messaging; pylint: disable-msg=protected-access # Monkey-patch storage for messaging; pylint: disable=protected-access
request._messages = fallback.FallbackStorage(request) request._messages = fallback.FallbackStorage(request)
middleware.ExceptionMiddleware().process_exception( middleware.ExceptionMiddleware().process_exception(
request, request,
...@@ -539,7 +542,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -539,7 +542,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# configure the backend, and mock out wire traffic. # configure the backend, and mock out wire traffic.
request, strategy = self.get_request_and_strategy( request, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
pipeline.analytics.track = mock.MagicMock() pipeline.analytics.track = mock.MagicMock()
user = self.create_user_models_for_existing_account( user = self.create_user_models_for_existing_account(
strategy, 'user@example.com', 'password', self.get_username()) strategy, 'user@example.com', 'password', self.get_username())
...@@ -558,8 +561,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -558,8 +561,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# Next, the provider makes a request against /auth/complete/<provider> # Next, the provider makes a request against /auth/complete/<provider>
# to resume the pipeline. # to resume the pipeline.
# pylint: disable-msg=protected-access # pylint: disable=protected-access
self.assert_redirect_to_login_looks_correct(actions.do_complete(strategy, social_views._do_login)) self.assert_redirect_to_login_looks_correct(actions.do_complete(request.backend, social_views._do_login))
mako_middleware_process_request(strategy.request) mako_middleware_process_request(strategy.request)
# At this point we know the pipeline has resumed correctly. Next we # At this point we know the pipeline has resumed correctly. Next we
...@@ -574,7 +577,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -574,7 +577,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# We should be redirected back to the complete page, setting # We should be redirected back to the complete page, setting
# the "logged in" cookie for the marketing site. # the "logged in" cookie for the marketing site.
self.assert_logged_in_cookie_redirect(actions.do_complete( self.assert_logged_in_cookie_redirect(actions.do_complete(
request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access request.backend, social_views._do_login, request.user, None, # pylint: disable=protected-access
redirect_field_name=auth.REDIRECT_FIELD_NAME redirect_field_name=auth.REDIRECT_FIELD_NAME
)) ))
...@@ -582,13 +585,13 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -582,13 +585,13 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
self.set_logged_in_cookie(request) self.set_logged_in_cookie(request)
self.assert_redirect_to_dashboard_looks_correct( self.assert_redirect_to_dashboard_looks_correct(
actions.do_complete(strategy, social_views._do_login, user=user)) actions.do_complete(request.backend, social_views._do_login, user=user))
self.assert_account_settings_context_looks_correct(account_settings_context(request), user) self.assert_account_settings_context_looks_correct(account_settings_context(request), user)
def test_signin_fails_if_account_not_active(self): def test_signin_fails_if_account_not_active(self):
_, strategy = self.get_request_and_strategy( _, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
user = self.create_user_models_for_existing_account(strategy, 'user@example.com', 'password', self.get_username()) user = self.create_user_models_for_existing_account(strategy, 'user@example.com', 'password', self.get_username())
user.is_active = False user.is_active = False
...@@ -600,7 +603,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -600,7 +603,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
def test_signin_fails_if_no_account_associated(self): def test_signin_fails_if_no_account_associated(self):
_, strategy = self.get_request_and_strategy( _, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
self.create_user_models_for_existing_account( self.create_user_models_for_existing_account(
strategy, 'user@example.com', 'password', self.get_username(), skip_social_auth=True) strategy, 'user@example.com', 'password', self.get_username(), skip_social_auth=True)
...@@ -625,7 +628,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -625,7 +628,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# Mock out wire traffic. # Mock out wire traffic.
request, strategy = self.get_request_and_strategy( request, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete') auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete')
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
# Begin! Grab the registration page and check the login control on it. # Begin! Grab the registration page and check the login control on it.
self.assert_register_response_before_pipeline_looks_correct(self.client.get('/register')) self.assert_register_response_before_pipeline_looks_correct(self.client.get('/register'))
...@@ -637,8 +640,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -637,8 +640,8 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))) pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN)))
# Next, the provider makes a request against /auth/complete/<provider>. # Next, the provider makes a request against /auth/complete/<provider>.
# pylint: disable-msg=protected-access # pylint: disable=protected-access
self.assert_redirect_to_register_looks_correct(actions.do_complete(strategy, social_views._do_login)) self.assert_redirect_to_register_looks_correct(actions.do_complete(request.backend, social_views._do_login))
mako_middleware_process_request(strategy.request) mako_middleware_process_request(strategy.request)
# At this point we know the pipeline has resumed correctly. Next we # At this point we know the pipeline has resumed correctly. Next we
...@@ -672,33 +675,18 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -672,33 +675,18 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# social auth. # social auth.
self.assert_social_auth_does_not_exist_for_user(created_user, strategy) self.assert_social_auth_does_not_exist_for_user(created_user, strategy)
# Since the user's account is not yet active, we should be redirected to /login # We should be redirected back to the complete page, setting
self.assert_redirect_to_login_looks_correct(
actions.do_complete(
request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access
redirect_field_name=auth.REDIRECT_FIELD_NAME
)
)
# Activate the user's account
strategy.request.user.is_active = True
strategy.request.user.save()
# Try again. This time, we should be redirected back to the complete page, setting
# the "logged in" cookie for the marketing site. # the "logged in" cookie for the marketing site.
self.assert_logged_in_cookie_redirect(actions.do_complete( self.assert_logged_in_cookie_redirect(actions.do_complete(
request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access request.backend, social_views._do_login, request.user, None, # pylint: disable=protected-access
redirect_field_name=auth.REDIRECT_FIELD_NAME redirect_field_name=auth.REDIRECT_FIELD_NAME
)) ))
# Set the cookie and try again # Set the cookie and try again
self.set_logged_in_cookie(request) self.set_logged_in_cookie(request)
# Pick the pipeline back up. This will create the account association
# and send the user to the dashboard, where the association will be
# displayed.
self.assert_redirect_to_dashboard_looks_correct( self.assert_redirect_to_dashboard_looks_correct(
actions.do_complete(strategy, social_views._do_login, user=created_user)) actions.do_complete(strategy.request.backend, social_views._do_login, user=created_user))
# Now the user has been redirected to the dashboard. Their third party account should now be linked.
self.assert_social_auth_exists_for_user(created_user, strategy) self.assert_social_auth_exists_for_user(created_user, strategy)
self.assert_account_settings_context_looks_correct(account_settings_context(request), created_user, linked=True) self.assert_account_settings_context_looks_correct(account_settings_context(request), created_user, linked=True)
...@@ -710,18 +698,20 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -710,18 +698,20 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# Create a colliding username in the backend, then proceed with # Create a colliding username in the backend, then proceed with
# assignment via pipeline to make sure a distinct username is created. # assignment via pipeline to make sure a distinct username is created.
strategy.storage.user.create_user(username=self.get_username(), email='user@email.com', password='password') strategy.storage.user.create_user(username=self.get_username(), email='user@email.com', password='password')
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) backend = strategy.request.backend
# pylint: disable-msg=protected-access backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
self.assert_redirect_to_register_looks_correct(actions.do_complete(strategy, social_views._do_login)) # pylint: disable=protected-access
self.assert_redirect_to_register_looks_correct(actions.do_complete(backend, social_views._do_login))
distinct_username = pipeline.get(request)['kwargs']['username'] distinct_username = pipeline.get(request)['kwargs']['username']
self.assertNotEqual(original_username, distinct_username) self.assertNotEqual(original_username, distinct_username)
def test_new_account_registration_fails_if_email_exists(self): def test_new_account_registration_fails_if_email_exists(self):
request, strategy = self.get_request_and_strategy( request, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete') auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete')
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) backend = strategy.request.backend
# pylint: disable-msg=protected-access backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
self.assert_redirect_to_register_looks_correct(actions.do_complete(strategy, social_views._do_login)) # pylint: disable=protected-access
self.assert_redirect_to_register_looks_correct(actions.do_complete(backend, social_views._do_login))
mako_middleware_process_request(strategy.request) mako_middleware_process_request(strategy.request)
self.assert_register_response_in_pipeline_looks_correct( self.assert_register_response_in_pipeline_looks_correct(
...@@ -733,21 +723,21 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -733,21 +723,21 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
def test_pipeline_raises_auth_entry_error_if_auth_entry_invalid(self): def test_pipeline_raises_auth_entry_error_if_auth_entry_invalid(self):
auth_entry = 'invalid' auth_entry = 'invalid'
self.assertNotIn(auth_entry, pipeline._AUTH_ENTRY_CHOICES) # pylint: disable-msg=protected-access self.assertNotIn(auth_entry, pipeline._AUTH_ENTRY_CHOICES) # pylint: disable=protected-access
_, strategy = self.get_request_and_strategy(auth_entry=auth_entry, redirect_uri='social:complete') _, strategy = self.get_request_and_strategy(auth_entry=auth_entry, redirect_uri='social:complete')
with self.assertRaises(pipeline.AuthEntryError): with self.assertRaises(pipeline.AuthEntryError):
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
def test_pipeline_raises_auth_entry_error_if_auth_entry_missing(self): def test_pipeline_raises_auth_entry_error_if_auth_entry_missing(self):
_, strategy = self.get_request_and_strategy(auth_entry=None, redirect_uri='social:complete') _, strategy = self.get_request_and_strategy(auth_entry=None, redirect_uri='social:complete')
with self.assertRaises(pipeline.AuthEntryError): with self.assertRaises(pipeline.AuthEntryError):
strategy.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
class Oauth2IntegrationTest(IntegrationTest): # pylint: disable-msg=abstract-method class Oauth2IntegrationTest(IntegrationTest): # pylint: disable=abstract-method
"""Base test case for integration tests of Oauth2 providers.""" """Base test case for integration tests of Oauth2 providers."""
# Dict of string -> object. Information about the token granted to the # Dict of string -> object. Information about the token granted to the
......
# -*- 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()
}
...@@ -245,36 +245,39 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase) ...@@ -245,36 +245,39 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase)
) )
@ddt.unpack @ddt.unpack
def test_login_and_registration_form_signin_preserves_params(self, is_edx_domain, url_name): def test_login_and_registration_form_signin_preserves_params(self, is_edx_domain, url_name):
params = { params = [
'enrollment_action': 'enroll', ('course_id', 'edX/DemoX/Demo_Course'),
'course_id': 'edX/DemoX/Demo_Course' ('enrollment_action', 'enroll'),
} ]
# The response should have a "Sign In" button with the URL # The response should have a "Sign In" button with the URL
# that preserves the querystring params # that preserves the querystring params
with mock.patch.dict(settings.FEATURES, {'IS_EDX_DOMAIN': is_edx_domain}): with mock.patch.dict(settings.FEATURES, {'IS_EDX_DOMAIN': is_edx_domain}):
response = self.client.get(reverse(url_name), params) response = self.client.get(reverse(url_name), params)
self.assertContains(response, "login?course_id=edX%2FDemoX%2FDemo_Course&enrollment_action=enroll") expected_url = '/login?{}'.format(self._finish_auth_url_param(params + [('next', '/dashboard')]))
self.assertContains(response, expected_url)
# Add an additional "course mode" parameter # Add additional parameters:
params['course_mode'] = 'honor' params = [
('course_id', 'edX/DemoX/Demo_Course'),
('enrollment_action', 'enroll'),
('course_mode', 'honor'),
('email_opt_in', 'true'),
('next', '/custom/final/destination')
]
# Verify that this parameter is also preserved # Verify that this parameter is also preserved
with mock.patch.dict(settings.FEATURES, {'IS_EDX_DOMAIN': is_edx_domain}): with mock.patch.dict(settings.FEATURES, {'IS_EDX_DOMAIN': is_edx_domain}):
response = self.client.get(reverse(url_name), params) response = self.client.get(reverse(url_name), params)
expected_url = ( expected_url = '/login?{}'.format(self._finish_auth_url_param(params))
"login?course_id=edX%2FDemoX%2FDemo_Course"
"&enrollment_action=enroll"
"&course_mode=honor"
)
self.assertContains(response, expected_url) self.assertContains(response, expected_url)
@mock.patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False}) @mock.patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False})
@ddt.data("account_login", "account_register") @ddt.data("account_login", "account_register")
def test_third_party_auth_disabled(self, url_name): def test_third_party_auth_disabled(self, url_name):
response = self.client.get(reverse(url_name)) response = self.client.get(reverse(url_name))
self._assert_third_party_auth_data(response, None, []) self._assert_third_party_auth_data(response, None, None, [])
@ddt.data( @ddt.data(
("account_login", None, None), ("account_login", None, None),
...@@ -286,175 +289,40 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase) ...@@ -286,175 +289,40 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase)
) )
@ddt.unpack @ddt.unpack
def test_third_party_auth(self, url_name, current_backend, current_provider): def test_third_party_auth(self, url_name, current_backend, current_provider):
params = [
('course_id', 'edX/DemoX/Demo_Course'),
('enrollment_action', 'enroll'),
('course_mode', 'honor'),
('email_opt_in', 'true'),
('next', '/custom/final/destination'),
]
# Simulate a running pipeline # Simulate a running pipeline
if current_backend is not None: if current_backend is not None:
pipeline_target = "student_account.views.third_party_auth.pipeline" pipeline_target = "student_account.views.third_party_auth.pipeline"
with simulate_running_pipeline(pipeline_target, current_backend): with simulate_running_pipeline(pipeline_target, current_backend):
response = self.client.get(reverse(url_name)) response = self.client.get(reverse(url_name), params)
# Do NOT simulate a running pipeline # Do NOT simulate a running pipeline
else: else:
response = self.client.get(reverse(url_name)) response = self.client.get(reverse(url_name), params)
# This relies on the THIRD_PARTY_AUTH configuration in the test settings # This relies on the THIRD_PARTY_AUTH configuration in the test settings
expected_providers = [ expected_providers = [
{ {
"name": "Facebook", "name": "Facebook",
"iconClass": "fa-facebook", "iconClass": "fa-facebook",
"loginUrl": self._third_party_login_url("facebook", "login"), "loginUrl": self._third_party_login_url("facebook", "login", params),
"registerUrl": self._third_party_login_url("facebook", "register") "registerUrl": self._third_party_login_url("facebook", "register", params)
},
{
"name": "Google",
"iconClass": "fa-google-plus",
"loginUrl": self._third_party_login_url("google-oauth2", "login"),
"registerUrl": self._third_party_login_url("google-oauth2", "register")
}
]
self._assert_third_party_auth_data(response, current_provider, expected_providers)
@ddt.data([], ["honor"], ["honor", "verified", "audit"], ["professional"], ["no-id-professional"])
def test_third_party_auth_course_id_verified(self, modes):
# Create a course with the specified course modes
course = CourseFactory.create()
for slug in modes:
CourseModeFactory.create(
course_id=course.id,
mode_slug=slug,
mode_display_name=slug
)
# Verify that the entry URL for third party auth
# contains the course ID and redirects to the track selection page.
course_modes_choose_url = reverse(
"course_modes_choose",
kwargs={"course_id": unicode(course.id)}
)
expected_providers = [
{
"name": "Facebook",
"iconClass": "fa-facebook",
"loginUrl": self._third_party_login_url(
"facebook", "login",
course_id=unicode(course.id),
redirect_url=course_modes_choose_url
),
"registerUrl": self._third_party_login_url(
"facebook", "register",
course_id=unicode(course.id),
redirect_url=course_modes_choose_url
)
},
{
"name": "Google",
"iconClass": "fa-google-plus",
"loginUrl": self._third_party_login_url(
"google-oauth2", "login",
course_id=unicode(course.id),
redirect_url=course_modes_choose_url
),
"registerUrl": self._third_party_login_url(
"google-oauth2", "register",
course_id=unicode(course.id),
redirect_url=course_modes_choose_url
)
}
]
# Verify that the login page contains the correct provider URLs
response = self.client.get(reverse("account_login"), {"course_id": unicode(course.id)})
self._assert_third_party_auth_data(response, None, expected_providers)
def test_third_party_auth_course_id_shopping_cart(self):
# Create a course with a white-label course mode
course = CourseFactory.create()
CourseModeFactory.create(
course_id=course.id,
mode_slug="honor",
mode_display_name="Honor",
min_price=100
)
# Verify that the entry URL for third party auth
# contains the course ID and redirects to the shopping cart
shoppingcart_url = reverse("shoppingcart.views.show_cart")
expected_providers = [
{
"name": "Facebook",
"iconClass": "fa-facebook",
"loginUrl": self._third_party_login_url(
"facebook", "login",
course_id=unicode(course.id),
redirect_url=shoppingcart_url
),
"registerUrl": self._third_party_login_url(
"facebook", "register",
course_id=unicode(course.id),
redirect_url=shoppingcart_url
)
}, },
{ {
"name": "Google", "name": "Google",
"iconClass": "fa-google-plus", "iconClass": "fa-google-plus",
"loginUrl": self._third_party_login_url( "loginUrl": self._third_party_login_url("google-oauth2", "login", params),
"google-oauth2", "login", "registerUrl": self._third_party_login_url("google-oauth2", "register", params)
course_id=unicode(course.id),
redirect_url=shoppingcart_url
),
"registerUrl": self._third_party_login_url(
"google-oauth2", "register",
course_id=unicode(course.id),
redirect_url=shoppingcart_url
)
} }
] ]
self._assert_third_party_auth_data(response, current_backend, current_provider, expected_providers)
# Verify that the login page contains the correct provider URLs
response = self.client.get(reverse("account_login"), {"course_id": unicode(course.id)})
self._assert_third_party_auth_data(response, None, expected_providers)
@mock.patch.dict(settings.FEATURES, {'EMBARGO': True})
def test_third_party_auth_enrollment_embargo(self):
course = CourseFactory.create()
# Start the pipeline attempting to enroll in a restricted course
with restrict_course(course.id) as redirect_url:
response = self.client.get(reverse("account_login"), {"course_id": unicode(course.id)})
# Expect that the course ID has been removed from the
# login URLs (so the user won't be enrolled) and
# the ?next param sends users to the blocked message.
expected_providers = [
{
"name": "Facebook",
"iconClass": "fa-facebook",
"loginUrl": self._third_party_login_url(
"facebook", "login",
course_id=unicode(course.id),
redirect_url=redirect_url
),
"registerUrl": self._third_party_login_url(
"facebook", "register",
course_id=unicode(course.id),
redirect_url=redirect_url
)
},
{
"name": "Google",
"iconClass": "fa-google-plus",
"loginUrl": self._third_party_login_url(
"google-oauth2", "login",
course_id=unicode(course.id),
redirect_url=redirect_url
),
"registerUrl": self._third_party_login_url(
"google-oauth2", "register",
course_id=unicode(course.id),
redirect_url=redirect_url
)
}
]
self._assert_third_party_auth_data(response, None, expected_providers)
@override_settings(SITE_NAME=settings.MICROSITE_TEST_HOSTNAME) @override_settings(SITE_NAME=settings.MICROSITE_TEST_HOSTNAME)
def test_microsite_uses_old_login_page(self): def test_microsite_uses_old_login_page(self):
...@@ -477,33 +345,42 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase) ...@@ -477,33 +345,42 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase)
self.assertContains(resp, "Register for Test Microsite") self.assertContains(resp, "Register for Test Microsite")
self.assertContains(resp, "register-form") self.assertContains(resp, "register-form")
def _assert_third_party_auth_data(self, response, current_provider, providers): def _assert_third_party_auth_data(self, response, current_backend, current_provider, providers):
"""Verify that third party auth info is rendered correctly in a DOM data attribute. """ """Verify that third party auth info is rendered correctly in a DOM data attribute. """
auth_info = markupsafe.escape( auth_info = markupsafe.escape(
json.dumps({ json.dumps({
"currentProvider": current_provider, "currentProvider": current_provider,
"providers": providers "providers": providers,
"finishAuthUrl": "/auth/complete/{}?".format(current_backend) if current_backend else None,
"errorMessage": None,
}) })
) )
expected_data = u"data-third-party-auth='{auth_info}'".format( expected_data = u"data-third-party-auth='{auth_info}'".format(
auth_info=auth_info auth_info=auth_info
) )
self.assertContains(response, expected_data) self.assertContains(response, expected_data)
def _third_party_login_url(self, backend_name, auth_entry, course_id=None, redirect_url=None): def _third_party_login_url(self, backend_name, auth_entry, login_params):
"""Construct the login URL to start third party authentication. """ """Construct the login URL to start third party authentication. """
params = [("auth_entry", auth_entry)] return u"{url}?auth_entry={auth_entry}&{param_str}".format(
if redirect_url:
params.append(("next", redirect_url))
if course_id:
params.append(("enroll_course_id", course_id))
return u"{url}?{params}".format(
url=reverse("social:begin", kwargs={"backend": backend_name}), url=reverse("social:begin", kwargs={"backend": backend_name}),
params=urlencode(params) auth_entry=auth_entry,
param_str=self._finish_auth_url_param(login_params),
) )
def _finish_auth_url_param(self, params):
"""
Make the next=... URL parameter that indicates where the user should go next.
>>> _finish_auth_url_param([('next', '/dashboard')])
'/account/finish_auth?next=%2Fdashboard'
"""
return urlencode({
'next': '/account/finish_auth?{}'.format(urlencode(params))
})
class AccountSettingsViewTest(TestCase): class AccountSettingsViewTest(TestCase):
""" Tests for the account settings view. """ """ Tests for the account settings view. """
......
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