diff --git a/common/djangoapps/external_auth/tests/test_helper.py b/common/djangoapps/external_auth/tests/test_helper.py
new file mode 100644
index 0000000000..ff463e3355
--- /dev/null
+++ b/common/djangoapps/external_auth/tests/test_helper.py
@@ -0,0 +1,29 @@
+"""
+Tests for utility functions in external_auth module
+"""
+from django.test import TestCase
+from external_auth.views import _safe_postlogin_redirect
+
+
+class ExternalAuthHelperFnTest(TestCase):
+ """
+ Unit tests for the external_auth.views helper function
+ """
+ def test__safe_postlogin_redirect(self):
+ """
+ Tests the _safe_postlogin_redirect function with different values of next
+ """
+ HOST = 'testserver' # pylint: disable=C0103
+ ONSITE1 = '/dashboard' # pylint: disable=C0103
+ ONSITE2 = '/courses/org/num/name/courseware' # pylint: disable=C0103
+ ONSITE3 = 'http://{}/my/custom/url'.format(HOST) # pylint: disable=C0103
+ OFFSITE1 = 'http://www.attacker.com' # pylint: disable=C0103
+
+ for redirect_to in [ONSITE1, ONSITE2, ONSITE3]:
+ redir = _safe_postlogin_redirect(redirect_to, HOST)
+ self.assertEqual(redir.status_code, 302)
+ self.assertEqual(redir['location'], redirect_to)
+
+ redir2 = _safe_postlogin_redirect(OFFSITE1, HOST)
+ self.assertEqual(redir2.status_code, 302)
+ self.assertEqual("/", redir2['location'])
diff --git a/common/djangoapps/external_auth/tests/test_shib.py b/common/djangoapps/external_auth/tests/test_shib.py
index 0355730256..2b6e2a4acf 100644
--- a/common/djangoapps/external_auth/tests/test_shib.py
+++ b/common/djangoapps/external_auth/tests/test_shib.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
"""
Tests for Shibboleth Authentication
@jbau
@@ -7,6 +8,7 @@ from mock import patch
from django.conf import settings
from django.http import HttpResponseRedirect
+from django.test import TestCase
from django.test.client import RequestFactory, Client as DjangoTestClient
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
@@ -19,7 +21,7 @@ from xmodule.modulestore.inheritance import own_metadata
from xmodule.modulestore.django import editable_modulestore
from external_auth.models import ExternalAuthMap
-from external_auth.views import shib_login, course_specific_login, course_specific_register
+from external_auth.views import shib_login, course_specific_login, course_specific_register, _flatten_to_ascii
from student.views import create_account, change_enrollment
from student.models import UserProfile, Registration, CourseEnrollment
@@ -32,11 +34,12 @@ TEST_DATA_MIXED_MODULESTORE = mixed_store_config(settings.COMMON_TEST_DATA_ROOT,
# b/c of how mod_shib works but should test the behavior with the rest of the attributes present/missing
# For the sake of python convention we'll make all of these variable names ALL_CAPS
-IDP = 'https://idp.stanford.edu/'
-REMOTE_USER = 'test_user@stanford.edu'
-MAILS = [None, '', 'test_user@stanford.edu']
-GIVENNAMES = [None, '', 'Jason', 'jas\xc3\xb6n; John; bob'] # At Stanford, the givenNames can be a list delimited by ';'
-SNS = [None, '', 'Bau', '\xe5\x8c\x85; smith'] # At Stanford, the sns can be a list delimited by ';'
+IDP = u'https://idp.stanford.edu/'
+REMOTE_USER = u'test_user@stanford.edu'
+MAILS = [None, u'', u'test_user@stanford.edu']
+DISPLAYNAMES = [None, u'', u'Jason \u5305']
+GIVENNAMES = [None, u'', u'jas\xf6n; John; bob'] # At Stanford, the givenNames can be a list delimited by ';'
+SNS = [None, u'', u'\u5305; smith'] # At Stanford, the sns can be a list delimited by ';'
def gen_all_identities():
@@ -46,10 +49,12 @@ def gen_all_identities():
could potentially pass to django via request.META, i.e.
setting (or not) request.META['givenName'], etc.
"""
- def _build_identity_dict(mail, given_name, surname):
+ def _build_identity_dict(mail, display_name, given_name, surname):
""" Helper function to return a dict of test identity """
meta_dict = {'Shib-Identity-Provider': IDP,
'REMOTE_USER': REMOTE_USER}
+ if display_name is not None:
+ meta_dict['displayName'] = display_name
if mail is not None:
meta_dict['mail'] = mail
if given_name is not None:
@@ -61,7 +66,8 @@ def gen_all_identities():
for mail in MAILS:
for given_name in GIVENNAMES:
for surname in SNS:
- yield _build_identity_dict(mail, given_name, surname)
+ for display_name in DISPLAYNAMES:
+ yield _build_identity_dict(mail, display_name, given_name, surname)
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE, SESSION_ENGINE='django.contrib.sessions.backends.cache')
@@ -75,7 +81,7 @@ class ShibSPTest(ModuleStoreTestCase):
def setUp(self):
self.store = editable_modulestore()
- @unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
+ @unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
def test_exception_shib_login(self):
"""
Tests that we get the error page when there is no REMOTE_USER
@@ -101,7 +107,7 @@ class ShibSPTest(ModuleStoreTestCase):
self.assertIn(u'logged in via Shibboleth', args[0])
self.assertEquals(remote_user, args[1])
- @unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
+ @unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
def test_shib_login(self):
"""
Tests that:
@@ -195,11 +201,13 @@ class ShibSPTest(ModuleStoreTestCase):
self.assertEquals(len(audit_log_calls), 0)
else:
self.assertEqual(response.status_code, 200)
- self.assertContains(response, "
Register for")
+ self.assertContains(response,
+ ("Preferences for {platform_name}"
+ .format(platform_name=settings.PLATFORM_NAME)))
# no audit logging calls
self.assertEquals(len(audit_log_calls), 0)
- @unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
+ @unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
def test_registration_form(self):
"""
Tests the registration form showing up with the proper parameters.
@@ -219,17 +227,18 @@ class ShibSPTest(ModuleStoreTestCase):
self.assertNotContains(response, mail_input_HTML)
sn_empty = not identity.get('sn')
given_name_empty = not identity.get('givenName')
- fullname_input_HTML = 'Log into your {platform_name} Account"
+ .format(platform_name=settings.PLATFORM_NAME)))
+ self.assertEqual(noshib_response.status_code, 200)
+
+ TARGET_URL_SHIB = reverse('courseware', args=[self.shib_course.id]) # pylint: disable=C0103
+ shib_response = self.client.get(**{'path': TARGET_URL_SHIB,
+ 'follow': True,
+ 'REMOTE_USER': self.extauth.external_id,
+ 'Shib-Identity-Provider': 'https://idp.stanford.edu/'})
+ # Test that the shib-login redirect page with ?next= and the desired page are part of the redirect chain
+ # The 'courseware' page actually causes a redirect itself, so it's not the end of the chain and we
+ # won't test its contents
+ self.assertEqual(shib_response.redirect_chain[-3],
+ ('http://testserver/shib-login/?next={url}'.format(url=TARGET_URL_SHIB), 302))
+ self.assertEqual(shib_response.redirect_chain[-2],
+ ('http://testserver{url}'.format(url=TARGET_URL_SHIB), 302))
+ self.assertEqual(shib_response.status_code, 200)
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 78691f9134..f6aaf4aa1e 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -23,7 +23,8 @@ from django.core.urlresolvers import reverse
from django.core.validators import validate_email, validate_slug, ValidationError
from django.core.exceptions import ObjectDoesNotExist
from django.db import IntegrityError, transaction
-from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotAllowed, Http404
+from django.http import (HttpResponse, HttpResponseBadRequest, HttpResponseForbidden,
+ HttpResponseNotAllowed, Http404)
from django.shortcuts import redirect
from django_future.csrf import ensure_csrf_cookie
from django.utils.http import cookie_date, base36_to_int, urlencode
@@ -54,6 +55,7 @@ from courseware.courses import get_courses, sort_by_announcement
from courseware.access import has_access
from external_auth.models import ExternalAuthMap
+import external_auth.views
from bulk_email.models import Optout
@@ -92,7 +94,7 @@ def index(request, extra_context={}, user=None):
# The course selection work is done in courseware.courses.
domain = settings.MITX_FEATURES.get('FORCE_UNIVERSITY_DOMAIN') # normally False
# do explicit check, because domain=None is valid
- if domain == False:
+ if domain is False:
domain = request.META.get('HTTP_HOST')
courses = get_courses(None, domain=domain)
@@ -252,6 +254,8 @@ def register_user(request, extra_context=None):
if extra_context is not None:
context.update(extra_context)
+ if context.get("extauth_domain", '').startswith(external_auth.views.SHIBBOLETH_DOMAIN_PREFIX):
+ return render_to_response('register-shib.html', context)
return render_to_response('register.html', context)
@@ -413,11 +417,49 @@ def change_enrollment(request):
else:
return HttpResponseBadRequest(_("Enrollment action is invalid"))
+
+def _parse_course_id_from_string(input_str):
+ """
+ Helper function to determine if input_str (typically the queryparam 'next') contains a course_id.
+ @param input_str:
+ @return: the course_id if found, None if not
+ """
+ m_obj = re.match(r'^/courses/(?P[^/]+/[^/]+/[^/]+)', input_str)
+ if m_obj:
+ return m_obj.group('course_id')
+ return None
+
+
+def _get_course_enrollment_domain(course_id):
+ """
+ Helper function to get the enrollment domain set for a course with id course_id
+ @param course_id:
+ @return:
+ """
+ try:
+ course = course_from_id(course_id)
+ return course.enrollment_domain
+ except ItemNotFoundError:
+ return None
+
+
@ensure_csrf_cookie
-def accounts_login(request, error=""):
+def accounts_login(request):
+ """
+ This view is mainly used as the redirect from the @login_required decorator. I don't believe that
+ the login path linked from the homepage uses it.
+ """
if settings.MITX_FEATURES.get('AUTH_USE_CAS'):
return redirect(reverse('cas-login'))
- return render_to_response('login.html', {'error': error})
+ # see if the "next" parameter has been set, whether it has a course context, and if so, whether
+ # there is a course-specific place to redirect
+ redirect_to = request.GET.get('next')
+ if redirect_to:
+ course_id = _parse_course_id_from_string(redirect_to)
+ if course_id and _get_course_enrollment_domain(course_id):
+ return external_auth.views.course_specific_login(request, course_id)
+ return render_to_response('login.html')
+
# Need different levels of logging
@ensure_csrf_cookie
@@ -435,6 +477,18 @@ def login_user(request, error=""):
AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email))
user = None
+ # check if the user has a linked shibboleth account, if so, redirect the user to shib-login
+ # This behavior is pretty much like what gmail does for shibboleth. Try entering some @stanford.edu
+ # address into the Gmail login.
+ if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and user:
+ try:
+ eamap = ExternalAuthMap.objects.get(user=user)
+ if eamap.external_domain.startswith(external_auth.views.SHIBBOLETH_DOMAIN_PREFIX):
+ return HttpResponse(json.dumps({'success': False, 'redirect': reverse('shib-login')}))
+ except ExternalAuthMap.DoesNotExist:
+ # This is actually the common case, logging in user without external linked login
+ AUDIT_LOG.info("User %s w/o external auth attempting login", user)
+
# if the user doesn't exist, we want to set the username to an invalid
# username so that authentication is guaranteed to fail and we can take
# advantage of the ratelimited backend
@@ -636,9 +690,10 @@ def create_account(request, post_override=None):
return HttpResponse(json.dumps(js))
# Can't have terms of service for certain SHIB users, like at Stanford
- tos_not_required = settings.MITX_FEATURES.get("AUTH_USE_SHIB") \
- and settings.MITX_FEATURES.get('SHIB_DISABLE_TOS') \
- and DoExternalAuth and ("shib" in eamap.external_domain)
+ tos_not_required = (settings.MITX_FEATURES.get("AUTH_USE_SHIB") and
+ settings.MITX_FEATURES.get('SHIB_DISABLE_TOS') and
+ DoExternalAuth and
+ eamap.external_domain.startswith(external_auth.views.SHIBBOLETH_DOMAIN_PREFIX))
if not tos_not_required:
if post_vars.get('terms_of_service', 'false') != u'true':
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 230b90a203..739cb92c1e 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -94,6 +94,8 @@ MITX_FEATURES = {
'AUTH_USE_OPENID': False,
'AUTH_USE_MIT_CERTIFICATES': False,
'AUTH_USE_OPENID_PROVIDER': False,
+ # Even though external_auth is in common, shib assumes the LMS views / urls, so it should only be enabled
+ # in LMS
'AUTH_USE_SHIB': False,
'AUTH_USE_CAS': False,
diff --git a/lms/templates/login.html b/lms/templates/login.html
index 5fad095024..dc7473320c 100644
--- a/lms/templates/login.html
+++ b/lms/templates/login.html
@@ -55,6 +55,12 @@
} else {
location.href="${reverse('dashboard')}";
}
+ } else if(json.hasOwnProperty('redirect')) {
+ var u=decodeURI(window.location.search);
+ if (!isExternal(json.redirect)) { // a paranoid check. Our server is the one providing json.redirect
+ location.href=json.redirect+u;
+ } // else we just remain on this page, which is fine since this particular path implies a login failure
+ // that has been generated via packet tampering (json.redirect has been messed with).
} else {
toggleSubmitButton(true);
$('.message.submission-error').addClass('is-shown').focus();
diff --git a/lms/templates/register-shib.html b/lms/templates/register-shib.html
new file mode 100644
index 0000000000..ba6f9a27bb
--- /dev/null
+++ b/lms/templates/register-shib.html
@@ -0,0 +1,195 @@
+<%! from django.utils.translation import ugettext as _ %>
+
+<%inherit file="main.html" />
+
+<%namespace name='static' file='static_content.html'/>
+<%namespace file='main.html' import="login_query"/>
+
+<%! from django.core.urlresolvers import reverse %>
+<%! from django.utils import html %>
+<%! from django_countries.countries import COUNTRIES %>
+<%! from student.models import UserProfile %>
+<%! from datetime import date %>
+<%! import calendar %>
+
+<%block name="title">${_("Preferences for {platform_name}").format(platform_name=settings.PLATFORM_NAME)}%block>
+
+<%block name="js_extra">
+
+%block>
+
+
+
+
${_("Welcome {username}! Please set your preferences below").format(username=extauth_id,
+ platform_name=settings.PLATFORM_NAME)}