Merge pull request #842 from edx/jbau/shib-revamp
Revamped + Enhanced Shibboleth support
This commit is contained in:
29
common/djangoapps/external_auth/tests/test_helper.py
Normal file
29
common/djangoapps/external_auth/tests/test_helper.py
Normal file
@@ -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'])
|
||||
@@ -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, "<title>Register for")
|
||||
self.assertContains(response,
|
||||
("<title>Preferences for {platform_name}</title>"
|
||||
.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 = '<input id="name" type="text" name="name"'
|
||||
if sn_empty and given_name_empty:
|
||||
self.assertContains(response, fullname_input_HTML)
|
||||
displayname_empty = not identity.get('displayName')
|
||||
fullname_input_html = '<input id="name" type="text" name="name"'
|
||||
if sn_empty and given_name_empty and displayname_empty:
|
||||
self.assertContains(response, fullname_input_html)
|
||||
else:
|
||||
self.assertNotContains(response, fullname_input_HTML)
|
||||
self.assertNotContains(response, fullname_input_html)
|
||||
|
||||
# clean up b/c we don't want existing ExternalAuthMap for the next run
|
||||
client.session['ExternalAuthMap'].delete()
|
||||
|
||||
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
|
||||
def test_registration_formSubmit(self):
|
||||
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
|
||||
def test_registration_form_submit(self):
|
||||
"""
|
||||
Tests user creation after the registration form that pops is submitted. If there is no shib
|
||||
ExternalAuthMap in the session, then the created user should take the username and email from the
|
||||
@@ -292,18 +301,26 @@ class ShibSPTest(ModuleStoreTestCase):
|
||||
profile = UserProfile.objects.get(user=user)
|
||||
sn_empty = not identity.get('sn')
|
||||
given_name_empty = not identity.get('givenName')
|
||||
if sn_empty and given_name_empty:
|
||||
self.assertEqual(profile.name, postvars['name'])
|
||||
displayname_empty = not identity.get('displayName')
|
||||
|
||||
if displayname_empty:
|
||||
if sn_empty and given_name_empty:
|
||||
self.assertEqual(profile.name, postvars['name'])
|
||||
else:
|
||||
self.assertEqual(profile.name, request2.session['ExternalAuthMap'].external_name)
|
||||
self.assertNotIn(u';', profile.name)
|
||||
else:
|
||||
self.assertEqual(profile.name, request2.session['ExternalAuthMap'].external_name)
|
||||
self.assertEqual(profile.name, identity.get('displayName'))
|
||||
|
||||
# clean up for next loop
|
||||
request2.session['ExternalAuthMap'].delete()
|
||||
UserProfile.objects.filter(user=user).delete()
|
||||
Registration.objects.filter(user=user).delete()
|
||||
user.delete()
|
||||
|
||||
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
|
||||
def test_course_specificLoginAndReg(self):
|
||||
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
|
||||
def test_course_specific_login_and_reg(self):
|
||||
"""
|
||||
Tests that the correct course specific login and registration urls work for shib
|
||||
"""
|
||||
@@ -322,8 +339,8 @@ class ShibSPTest(ModuleStoreTestCase):
|
||||
'?course_id=MITx/999/Robot_Super_Course' +
|
||||
'&enrollment_action=enroll')
|
||||
_reg_request = self.request_factory.get('/course_specific_register/MITx/999/Robot_Super_Course' +
|
||||
'?course_id=MITx/999/course/Robot_Super_Course' +
|
||||
'&enrollment_action=enroll')
|
||||
'?course_id=MITx/999/course/Robot_Super_Course' +
|
||||
'&enrollment_action=enroll')
|
||||
|
||||
login_response = course_specific_login(login_request, 'MITx/999/Robot_Super_Course')
|
||||
reg_response = course_specific_register(login_request, 'MITx/999/Robot_Super_Course')
|
||||
@@ -357,8 +374,8 @@ class ShibSPTest(ModuleStoreTestCase):
|
||||
'?course_id=DNE/DNE/DNE' +
|
||||
'&enrollment_action=enroll')
|
||||
_reg_request = self.request_factory.get('/course_specific_register/DNE/DNE/DNE' +
|
||||
'?course_id=DNE/DNE/DNE/Robot_Super_Course' +
|
||||
'&enrollment_action=enroll')
|
||||
'?course_id=DNE/DNE/DNE/Robot_Super_Course' +
|
||||
'&enrollment_action=enroll')
|
||||
|
||||
login_response = course_specific_login(login_request, 'DNE/DNE/DNE')
|
||||
reg_response = course_specific_register(login_request, 'DNE/DNE/DNE')
|
||||
@@ -374,7 +391,7 @@ class ShibSPTest(ModuleStoreTestCase):
|
||||
'?course_id=DNE/DNE/DNE' +
|
||||
'&enrollment_action=enroll')
|
||||
|
||||
@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_enrollment_limit_by_domain(self):
|
||||
"""
|
||||
Tests that the enrollmentDomain setting is properly limiting enrollment to those who have
|
||||
@@ -438,10 +455,12 @@ class ShibSPTest(ModuleStoreTestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(CourseEnrollment.is_enrolled(student, course.id))
|
||||
|
||||
@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_enrollment(self):
|
||||
"""
|
||||
A functionality test that a student with an existing shib login can auto-enroll in a class with GET params
|
||||
A functionality test that a student with an existing shib login
|
||||
can auto-enroll in a class with GET or POST params. Also tests the direction functionality of
|
||||
the 'next' GET/POST param
|
||||
"""
|
||||
student = UserFactory.create()
|
||||
extauth = ExternalAuthMap(external_id='testuser@stanford.edu',
|
||||
@@ -465,13 +484,38 @@ class ShibSPTest(ModuleStoreTestCase):
|
||||
self.assertFalse(CourseEnrollment.is_enrolled(student, course.id))
|
||||
self.client.logout()
|
||||
request_kwargs = {'path': '/shib-login/',
|
||||
'data': {'enrollment_action': 'enroll', 'course_id': course.id},
|
||||
'data': {'enrollment_action': 'enroll', 'course_id': course.id, 'next': '/testredirect'},
|
||||
'follow': False,
|
||||
'REMOTE_USER': 'testuser@stanford.edu',
|
||||
'Shib-Identity-Provider': 'https://idp.stanford.edu/'}
|
||||
response = self.client.get(**request_kwargs)
|
||||
# successful login is a redirect to "/"
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response['location'], 'http://testserver/')
|
||||
self.assertEqual(response['location'], 'http://testserver/testredirect')
|
||||
# now there is enrollment
|
||||
self.assertTrue(CourseEnrollment.is_enrolled(student, course.id))
|
||||
|
||||
# Clean up and try again with POST (doesn't happen with real production shib, doing this for test coverage)
|
||||
self.client.logout()
|
||||
CourseEnrollment.unenroll(student, course.id)
|
||||
self.assertFalse(CourseEnrollment.is_enrolled(student, course.id))
|
||||
|
||||
response = self.client.post(**request_kwargs)
|
||||
# successful login is a redirect to "/"
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response['location'], 'http://testserver/testredirect')
|
||||
# now there is enrollment
|
||||
self.assertTrue(CourseEnrollment.is_enrolled(student, course.id))
|
||||
|
||||
|
||||
class ShibUtilFnTest(TestCase):
|
||||
"""
|
||||
Tests util functions in shib module
|
||||
"""
|
||||
def test__flatten_to_ascii(self):
|
||||
DIACRITIC = u"àèìòùÀÈÌÒÙáéíóúýÁÉÍÓÚÝâêîôûÂÊÎÔÛãñõÃÑÕäëïöüÿÄËÏÖÜŸåÅçÇ" # pylint: disable=C0103
|
||||
FLATTENED = u"aeiouAEIOUaeiouyAEIOUYaeiouAEIOUanoANOaeiouyAEIOUYaAcC" # pylint: disable=C0103
|
||||
self.assertEqual(_flatten_to_ascii(u'jas\xf6n'), u'jason') # umlaut
|
||||
self.assertEqual(_flatten_to_ascii(u'Jason\u5305'), u'Jason') # mandarin, so it just gets dropped
|
||||
self.assertEqual(_flatten_to_ascii(u'abc'), u'abc') # pass through
|
||||
self.assertEqual(_flatten_to_ascii(DIACRITIC), FLATTENED)
|
||||
|
||||
@@ -5,13 +5,14 @@ import random
|
||||
import re
|
||||
import string # pylint: disable=W0402
|
||||
import fnmatch
|
||||
import unicodedata
|
||||
|
||||
from textwrap import dedent
|
||||
from external_auth.models import ExternalAuthMap
|
||||
from external_auth.djangostore import DjangoOpenIDStore
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login
|
||||
from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login, logout
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.validators import validate_email
|
||||
@@ -23,7 +24,7 @@ if settings.MITX_FEATURES.get('AUTH_USE_CAS'):
|
||||
from student.models import UserProfile, TestCenterUser, TestCenterRegistration
|
||||
|
||||
from django.http import HttpResponse, HttpResponseRedirect, HttpRequest, HttpResponseForbidden
|
||||
from django.utils.http import urlquote
|
||||
from django.utils.http import urlquote, is_safe_url
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
@@ -44,7 +45,7 @@ from openid.server.trustroot import TrustRoot
|
||||
from openid.extensions import ax, sreg
|
||||
from ratelimitbackend.exceptions import RateLimitException
|
||||
|
||||
import student.views as student_views
|
||||
import student.views
|
||||
# Required for Pearson
|
||||
from courseware.views import get_module_for_descriptor, jump_to
|
||||
from courseware.model_data import FieldDataCache
|
||||
@@ -136,6 +137,7 @@ def _external_login_or_signup(request,
|
||||
fullname,
|
||||
retfun=None):
|
||||
"""Generic external auth login or signup"""
|
||||
logout(request)
|
||||
|
||||
# see if we have a map from this external_id to an edX username
|
||||
try:
|
||||
@@ -158,15 +160,18 @@ def _external_login_or_signup(request,
|
||||
internal_user = eamap.user
|
||||
if internal_user is None:
|
||||
if uses_shibboleth:
|
||||
# if we are using shib, try to link accounts using email
|
||||
# If we are using shib, try to link accounts
|
||||
# For Stanford shib, the email the idp returns is actually under the control of the user.
|
||||
# Since the id the idps return is not user-editable, and is of the from "username@stanford.edu",
|
||||
# use the id to link accounts instead.
|
||||
try:
|
||||
link_user = User.objects.get(email=eamap.external_email)
|
||||
link_user = User.objects.get(email=eamap.external_id)
|
||||
if not ExternalAuthMap.objects.filter(user=link_user).exists():
|
||||
# if there's no pre-existing linked eamap, we link the user
|
||||
eamap.user = link_user
|
||||
eamap.save()
|
||||
internal_user = link_user
|
||||
log.info('SHIB: Linking existing account for %s', eamap.external_email)
|
||||
log.info('SHIB: Linking existing account for %s', eamap.external_id)
|
||||
# now pass through to log in
|
||||
else:
|
||||
# otherwise, there must have been an error, b/c we've already linked a user with these external
|
||||
@@ -215,15 +220,24 @@ def _external_login_or_signup(request,
|
||||
# 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)
|
||||
student.views.try_change_enrollment(enroll_request)
|
||||
else:
|
||||
student_views.try_change_enrollment(request)
|
||||
student.views.try_change_enrollment(request)
|
||||
AUDIT_LOG.info("Login success - %s (%s)", user.username, user.email)
|
||||
if retfun is None:
|
||||
return redirect('/')
|
||||
return retfun()
|
||||
|
||||
|
||||
def _flatten_to_ascii(txt):
|
||||
"""
|
||||
Flattens possibly unicode txt to ascii (django username limitation)
|
||||
@param name:
|
||||
@return:
|
||||
"""
|
||||
return unicodedata.normalize('NFKD', txt).encode('ASCII', 'ignore')
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_if_anonymous
|
||||
def _signup(request, eamap):
|
||||
@@ -239,11 +253,13 @@ def _signup(request, eamap):
|
||||
# save this for use by student.views.create_account
|
||||
request.session['ExternalAuthMap'] = eamap
|
||||
|
||||
# default conjoin name, no spaces
|
||||
username = eamap.external_name.replace(' ', '')
|
||||
# default conjoin name, no spaces, flattened to ascii b/c django can't handle unicode usernames, sadly
|
||||
# but this only affects username, not fullname
|
||||
username = re.sub(r'\s', '', _flatten_to_ascii(eamap.external_name), flags=re.UNICODE)
|
||||
|
||||
context = {'has_extauth_info': True,
|
||||
'show_signup_immediately': True,
|
||||
'extauth_domain': eamap.external_domain,
|
||||
'extauth_id': eamap.external_id,
|
||||
'extauth_email': eamap.external_email,
|
||||
'extauth_username': username,
|
||||
@@ -270,7 +286,7 @@ def _signup(request, eamap):
|
||||
|
||||
log.info('EXTAUTH: Doing signup for %s', eamap.external_id)
|
||||
|
||||
return student_views.register_user(request, extra_context=context)
|
||||
return student.views.register_user(request, extra_context=context)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -368,11 +384,11 @@ def ssl_login(request):
|
||||
|
||||
if not cert:
|
||||
# no certificate information - go onward to main index
|
||||
return student_views.index(request)
|
||||
return student.views.index(request)
|
||||
|
||||
(_user, email, fullname) = _ssl_dn_extract_info(cert)
|
||||
|
||||
retfun = functools.partial(student_views.index, request)
|
||||
retfun = functools.partial(student.views.index, request)
|
||||
return _external_login_or_signup(
|
||||
request,
|
||||
external_id=email,
|
||||
@@ -435,27 +451,49 @@ def shib_login(request):
|
||||
else:
|
||||
# If we get here, the user has authenticated properly
|
||||
shib = {attr: request.META.get(attr, '')
|
||||
for attr in ['REMOTE_USER', 'givenName', 'sn', 'mail', 'Shib-Identity-Provider']}
|
||||
for attr in ['REMOTE_USER', 'givenName', 'sn', 'mail', 'Shib-Identity-Provider', 'displayName']}
|
||||
|
||||
# Clean up first name, last name, and email address
|
||||
# TODO: Make this less hardcoded re: format, but split will work
|
||||
# even if ";" is not present, since we are accessing 1st element
|
||||
shib['sn'] = shib['sn'].split(";")[0].strip().capitalize().decode('utf-8')
|
||||
shib['givenName'] = shib['givenName'].split(";")[0].strip().capitalize().decode('utf-8')
|
||||
shib['sn'] = shib['sn'].split(";")[0].strip().capitalize()
|
||||
shib['givenName'] = shib['givenName'].split(";")[0].strip().capitalize()
|
||||
|
||||
# TODO: should we be logging creds here, at info level?
|
||||
log.info("SHIB creds returned: %r", shib)
|
||||
|
||||
fullname = shib['displayName'] if shib['displayName'] else u'%s %s' % (shib['givenName'], shib['sn'])
|
||||
|
||||
redirect_to = request.REQUEST.get('next')
|
||||
retfun = None
|
||||
if redirect_to:
|
||||
retfun = functools.partial(_safe_postlogin_redirect, redirect_to, request.get_host())
|
||||
|
||||
return _external_login_or_signup(
|
||||
request,
|
||||
external_id=shib['REMOTE_USER'],
|
||||
external_domain=SHIBBOLETH_DOMAIN_PREFIX + shib['Shib-Identity-Provider'],
|
||||
credentials=shib,
|
||||
email=shib['mail'],
|
||||
fullname=u'%s %s' % (shib['givenName'], shib['sn']),
|
||||
fullname=fullname,
|
||||
retfun=retfun
|
||||
)
|
||||
|
||||
|
||||
def _safe_postlogin_redirect(redirect_to, safehost, default_redirect='/'):
|
||||
"""
|
||||
If redirect_to param is safe (not off this host), then perform the redirect.
|
||||
Otherwise just redirect to '/'.
|
||||
Basically copied from django.contrib.auth.views.login
|
||||
@param redirect_to: user-supplied redirect url
|
||||
@param safehost: which host is safe to redirect to
|
||||
@return: an HttpResponseRedirect
|
||||
"""
|
||||
if is_safe_url(url=redirect_to, host=safehost):
|
||||
return redirect(redirect_to)
|
||||
return redirect(default_redirect)
|
||||
|
||||
|
||||
def _make_shib_enrollment_request(request):
|
||||
"""
|
||||
Need this hack function because shibboleth logins don't happen over POST
|
||||
@@ -486,20 +524,18 @@ def course_specific_login(request, course_id):
|
||||
Dispatcher function for selecting the specific login method
|
||||
required by the course
|
||||
"""
|
||||
query_string = request.META.get("QUERY_STRING", '')
|
||||
|
||||
try:
|
||||
course = course_from_id(course_id)
|
||||
except ItemNotFoundError:
|
||||
# couldn't find the course, will just return vanilla signin page
|
||||
return redirect_with_querystring('signin_user', query_string)
|
||||
return _redirect_with_get_querydict('signin_user', request.GET)
|
||||
|
||||
# now the dispatching conditionals. Only shib for now
|
||||
if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX):
|
||||
return redirect_with_querystring('shib-login', query_string)
|
||||
return _redirect_with_get_querydict('shib-login', request.GET)
|
||||
|
||||
# Default fallthrough to normal signin page
|
||||
return redirect_with_querystring('signin_user', query_string)
|
||||
return _redirect_with_get_querydict('signin_user', request.GET)
|
||||
|
||||
|
||||
def course_specific_register(request, course_id):
|
||||
@@ -507,29 +543,28 @@ def course_specific_register(request, course_id):
|
||||
Dispatcher function for selecting the specific registration method
|
||||
required by the course
|
||||
"""
|
||||
query_string = request.META.get("QUERY_STRING", '')
|
||||
|
||||
try:
|
||||
course = course_from_id(course_id)
|
||||
except ItemNotFoundError:
|
||||
# couldn't find the course, will just return vanilla registration page
|
||||
return redirect_with_querystring('register_user', query_string)
|
||||
return _redirect_with_get_querydict('register_user', request.GET)
|
||||
|
||||
# now the dispatching conditionals. Only shib for now
|
||||
if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX):
|
||||
# shib-login takes care of both registration and login flows
|
||||
return redirect_with_querystring('shib-login', query_string)
|
||||
return _redirect_with_get_querydict('shib-login', request.GET)
|
||||
|
||||
# Default fallthrough to normal registration page
|
||||
return redirect_with_querystring('register_user', query_string)
|
||||
return _redirect_with_get_querydict('register_user', request.GET)
|
||||
|
||||
|
||||
def redirect_with_querystring(view_name, query_string):
|
||||
def _redirect_with_get_querydict(view_name, get_querydict):
|
||||
"""
|
||||
Helper function to add query string to redirect views
|
||||
Helper function to carry over get parameters across redirects
|
||||
Using urlencode(safe='/') because the @login_required decorator generates 'next' queryparams with '/' unencoded
|
||||
"""
|
||||
if query_string:
|
||||
return redirect("%s?%s" % (reverse(view_name), query_string))
|
||||
if get_querydict:
|
||||
return redirect("%s?%s" % (reverse(view_name), get_querydict.urlencode(safe='/')))
|
||||
return redirect(view_name)
|
||||
|
||||
|
||||
|
||||
@@ -2,14 +2,26 @@
|
||||
Tests for student activation and login
|
||||
'''
|
||||
import json
|
||||
import unittest
|
||||
from mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test.client import Client
|
||||
from django.test.utils import override_settings
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.urlresolvers import reverse, NoReverseMatch
|
||||
from student.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory
|
||||
from student.views import _parse_course_id_from_string, _get_course_enrollment_domain
|
||||
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, mixed_store_config
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.modulestore.django import editable_modulestore
|
||||
|
||||
from external_auth.models import ExternalAuthMap
|
||||
|
||||
TEST_DATA_MIXED_MODULESTORE = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {})
|
||||
|
||||
class LoginTest(TestCase):
|
||||
'''
|
||||
@@ -154,13 +166,109 @@ class LoginTest(TestCase):
|
||||
|
||||
def _assert_audit_log(self, mock_audit_log, level, log_strings):
|
||||
"""
|
||||
Check that the audit log has received the expected call.
|
||||
Check that the audit log has received the expected call as its last call.
|
||||
"""
|
||||
method_calls = mock_audit_log.method_calls
|
||||
self.assertEquals(len(method_calls), 1)
|
||||
name, args, _kwargs = method_calls[0]
|
||||
name, args, _kwargs = method_calls[-1]
|
||||
self.assertEquals(name, level)
|
||||
self.assertEquals(len(args), 1)
|
||||
format_string = args[0]
|
||||
for log_string in log_strings:
|
||||
self.assertIn(log_string, format_string)
|
||||
|
||||
|
||||
class UtilFnTest(TestCase):
|
||||
"""
|
||||
Tests for utility functions in student.views
|
||||
"""
|
||||
def test__parse_course_id_from_string(self):
|
||||
"""
|
||||
Tests the _parse_course_id_from_string util function
|
||||
"""
|
||||
COURSE_ID = u'org/num/run' # pylint: disable=C0103
|
||||
COURSE_URL = u'/courses/{}/otherstuff'.format(COURSE_ID) # pylint: disable=C0103
|
||||
NON_COURSE_URL = u'/blahblah' # pylint: disable=C0103
|
||||
self.assertEqual(_parse_course_id_from_string(COURSE_URL), COURSE_ID)
|
||||
self.assertIsNone(_parse_course_id_from_string(NON_COURSE_URL))
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
|
||||
class ExternalAuthShibTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests how login_user() interacts with ExternalAuth, in particular Shib
|
||||
"""
|
||||
def setUp(self):
|
||||
self.store = editable_modulestore()
|
||||
self.course = CourseFactory.create(org='Stanford', number='456', display_name='NO SHIB')
|
||||
self.shib_course = CourseFactory.create(org='Stanford', number='123', display_name='Shib Only')
|
||||
self.shib_course.enrollment_domain = 'shib:https://idp.stanford.edu/'
|
||||
metadata = own_metadata(self.shib_course)
|
||||
metadata['enrollment_domain'] = self.shib_course.enrollment_domain
|
||||
self.store.update_metadata(self.shib_course.location.url(), metadata)
|
||||
self.user_w_map = UserFactory.create(email='withmap@stanford.edu')
|
||||
self.extauth = ExternalAuthMap(external_id='withmap@stanford.edu',
|
||||
external_email='withmap@stanford.edu',
|
||||
external_domain='shib:https://idp.stanford.edu/',
|
||||
external_credentials="",
|
||||
user=self.user_w_map)
|
||||
self.user_w_map.save()
|
||||
self.extauth.save()
|
||||
self.user_wo_map = UserFactory.create(email='womap@gmail.com')
|
||||
self.user_wo_map.save()
|
||||
|
||||
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
|
||||
def test_login_page_redirect(self):
|
||||
"""
|
||||
Tests that when a shib user types their email address into the login page, they get redirected
|
||||
to the shib login.
|
||||
"""
|
||||
response = self.client.post(reverse('login'), {'email': self.user_w_map.email, 'password': ''})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.content, json.dumps({'success': False, 'redirect': reverse('shib-login')}))
|
||||
|
||||
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
|
||||
def test__get_course_enrollment_domain(self):
|
||||
"""
|
||||
Tests the _get_course_enrollment_domain utility function
|
||||
"""
|
||||
self.assertIsNone(_get_course_enrollment_domain("I/DONT/EXIST"))
|
||||
self.assertIsNone(_get_course_enrollment_domain(self.course.id))
|
||||
self.assertEqual(self.shib_course.enrollment_domain, _get_course_enrollment_domain(self.shib_course.id))
|
||||
|
||||
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
|
||||
def test_login_required_dashboard(self):
|
||||
"""
|
||||
Tests redirects to when @login_required to dashboard, which should always be the normal login,
|
||||
since there is no course context
|
||||
"""
|
||||
response = self.client.get(reverse('dashboard'))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response['Location'], 'http://testserver/accounts/login?next=/dashboard')
|
||||
|
||||
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
|
||||
def test_externalauth_login_required_course_context(self):
|
||||
"""
|
||||
Tests the redirects when visiting course-specific URL with @login_required.
|
||||
Should vary by course depending on its enrollment_domain
|
||||
"""
|
||||
TARGET_URL = reverse('courseware', args=[self.course.id]) # pylint: disable=C0103
|
||||
noshib_response = self.client.get(TARGET_URL, follow=True)
|
||||
self.assertEqual(noshib_response.redirect_chain[-1],
|
||||
('http://testserver/accounts/login?next={url}'.format(url=TARGET_URL), 302))
|
||||
self.assertContains(noshib_response, ("<title>Log into your {platform_name} Account</title>"
|
||||
.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)
|
||||
|
||||
@@ -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<course_id>[^/]+/[^/]+/[^/]+)', 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':
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
195
lms/templates/register-shib.html
Normal file
195
lms/templates/register-shib.html
Normal file
@@ -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"><title>${_("Preferences for {platform_name}").format(platform_name=settings.PLATFORM_NAME)}</title></%block>
|
||||
|
||||
<%block name="js_extra">
|
||||
<script type="text/javascript">
|
||||
$(function() {
|
||||
|
||||
var view_name = 'view-register';
|
||||
|
||||
// adding js class for styling with accessibility in mind
|
||||
$('body').addClass('js').addClass(view_name);
|
||||
|
||||
// new window/tab opening
|
||||
$('a[rel="external"], a[class="new-vp"]')
|
||||
.click( function() {
|
||||
window.open( $(this).attr('href') );
|
||||
return false;
|
||||
});
|
||||
|
||||
// form field label styling on focus
|
||||
$("form :input").focus(function() {
|
||||
$("label[for='" + this.id + "']").parent().addClass("is-focused");
|
||||
}).blur(function() {
|
||||
$("label").parent().removeClass("is-focused");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
(function() {
|
||||
toggleSubmitButton(true);
|
||||
|
||||
$('#register-form').on('submit', function() {
|
||||
toggleSubmitButton(false);
|
||||
});
|
||||
|
||||
$('#register-form').on('ajax:error', function() {
|
||||
toggleSubmitButton(true);
|
||||
});
|
||||
|
||||
$('#register-form').on('ajax:success', function(event, json, xhr) {
|
||||
if(json.success) {
|
||||
location.href="${reverse('dashboard')}";
|
||||
} else {
|
||||
toggleSubmitButton(true);
|
||||
$('.status.message.submission-error').addClass('is-shown').focus();
|
||||
$('.status.message.submission-error .message-copy').html(json.value).stop().css("display", "block");
|
||||
$(".field-error").removeClass('field-error');
|
||||
$("[data-field='"+json.field+"']").addClass('field-error')
|
||||
}
|
||||
});
|
||||
})(this);
|
||||
|
||||
function toggleSubmitButton(enable) {
|
||||
var $submitButton = $('form .form-actions #submit');
|
||||
|
||||
if(enable) {
|
||||
$submitButton.
|
||||
removeClass('is-disabled').
|
||||
removeProp('disabled').
|
||||
html("${_('Update my {platform_name} Account').format(platform_name=settings.PLATFORM_NAME)}");
|
||||
}
|
||||
else {
|
||||
$submitButton.
|
||||
addClass('is-disabled').
|
||||
prop('disabled', true).
|
||||
html(gettext('Processing your account information …'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</%block>
|
||||
|
||||
<section class="introduction">
|
||||
<header>
|
||||
<h1 class="sr">${_("Welcome {username}! Please set your preferences below").format(username=extauth_id,
|
||||
platform_name=settings.PLATFORM_NAME)}</h1>
|
||||
</header>
|
||||
</section>
|
||||
|
||||
<%block name="login_button"></%block>
|
||||
<section class="register container">
|
||||
<section role="main" class="content">
|
||||
<form role="form" id="register-form" method="post" data-remote="true" action="/create_account" novalidate>
|
||||
|
||||
<!-- status messages -->
|
||||
<div role="alert" class="status message">
|
||||
<h3 class="message-title">${_("We're sorry, {platform_name} enrollment is not available in your region").format(platform_name=settings.PLATFORM_NAME)}</h3>
|
||||
<p class="message-copy">Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.</p>
|
||||
</div>
|
||||
|
||||
<div role="alert" class="status message submission-error" tabindex="-1">
|
||||
<h3 class="message-title">${_("The following errors occured while processing your registration:")} </h3>
|
||||
<ul class="message-copy"> </ul>
|
||||
</div>
|
||||
|
||||
<p class="instructions">
|
||||
${_('Required fields are noted by <strong class="indicator">bold text and an asterisk (*)</strong>.')}
|
||||
</p>
|
||||
|
||||
<fieldset class="group group-form group-form-requiredinformation">
|
||||
<legend class="sr">${_('Required Information')}</legend>
|
||||
|
||||
<div class="message">
|
||||
<p class="message-copy">${_("Enter a public username:")}</p>
|
||||
</div>
|
||||
|
||||
<ol class="list-input">
|
||||
|
||||
<li class="field required text" id="field-username">
|
||||
<label for="username">${_('Public Username')}</label>
|
||||
<input id="username" type="text" name="username" value="${extauth_username}" placeholder="${_('example: JaneDoe')}" required aria-required="true" />
|
||||
<span class="tip tip-input">${_('Will be shown in any discussions or forums you participate in')}</span>
|
||||
</li>
|
||||
|
||||
% if ask_for_email:
|
||||
|
||||
<li class="field required text" id="field-email">
|
||||
<label for="email">${_("E-mail")}</label>
|
||||
<input class="" id="email" type="email" name="email" value="" placeholder="${_('example: username@domain.com')}" />
|
||||
</li>
|
||||
|
||||
% endif
|
||||
|
||||
|
||||
% if ask_for_fullname:
|
||||
|
||||
<li class="field required text" id="field-name">
|
||||
<label for="name">${_('Full Name')}</label>
|
||||
<input id="name" type="text" name="name" value="" placeholder="$_('example: Jane Doe')}" />
|
||||
</li>
|
||||
|
||||
% endif
|
||||
|
||||
</ol>
|
||||
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="group group-form group-form-accountacknowledgements">
|
||||
<legend class="sr">${_("Account Acknowledgements")}</legend>
|
||||
|
||||
<ol class="list-input">
|
||||
<li class="field-group">
|
||||
|
||||
% if ask_for_tos :
|
||||
|
||||
<div class="field required checkbox" id="field-tos">
|
||||
<input id="tos-yes" type="checkbox" name="terms_of_service" value="true" required aria-required="true" />
|
||||
<label for="tos-yes">${_('I agree to the {link_start}Terms of Service{link_end}').format(
|
||||
link_start='<a href="{url}" class="new-vp">'.format(url=marketing_link('TOS')),
|
||||
link_end='</a>')}</label>
|
||||
</div>
|
||||
|
||||
% endif
|
||||
|
||||
<div class="field required checkbox" id="field-honorcode">
|
||||
<input id="honorcode-yes" type="checkbox" name="honor_code" value="true" />
|
||||
<%
|
||||
## TODO: provide a better way to override these links
|
||||
if self.stanford_theme_enabled():
|
||||
honor_code_path = marketing_link('TOS') + "#honor"
|
||||
else:
|
||||
honor_code_path = marketing_link('HONOR')
|
||||
%>
|
||||
<label for="honorcode-yes">${_('I agree to the {link_start}Honor Code{link_end}').format(
|
||||
link_start='<a href="{url}" class="new-vp">'.format(url=honor_code_path),
|
||||
link_end='</a>')}</label>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</fieldset>
|
||||
|
||||
% 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
|
||||
|
||||
<div class="form-actions">
|
||||
<button name="submit" type="submit" id="submit" class="action action-primary action-update">${_('Submit')} <span class="orn-plus">+</span> ${_('Update My Account')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
</section>
|
||||
Reference in New Issue
Block a user