diff --git a/common/djangoapps/external_auth/migrations/0001_initial.py b/common/djangoapps/external_auth/migrations/0001_initial.py new file mode 100644 index 0000000000..a5ebf023df --- /dev/null +++ b/common/djangoapps/external_auth/migrations/0001_initial.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'ExternalAuthMap' + db.create_table('external_auth_externalauthmap', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('external_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('external_domain', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('external_credentials', self.gf('django.db.models.fields.TextField')(blank=True)), + ('external_email', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('external_name', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=255, blank=True)), + ('user', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['auth.User'], unique=True, null=True)), + ('internal_password', self.gf('django.db.models.fields.CharField')(max_length=31, blank=True)), + ('dtcreated', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('dtsignup', self.gf('django.db.models.fields.DateTimeField')(null=True)), + )) + db.send_create_signal('external_auth', ['ExternalAuthMap']) + + # Adding unique constraint on 'ExternalAuthMap', fields ['external_id', 'external_domain'] + db.create_unique('external_auth_externalauthmap', ['external_id', 'external_domain']) + + + def backwards(self, orm): + # Removing unique constraint on 'ExternalAuthMap', fields ['external_id', 'external_domain'] + db.delete_unique('external_auth_externalauthmap', ['external_id', 'external_domain']) + + # Deleting model 'ExternalAuthMap' + db.delete_table('external_auth_externalauthmap') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'external_auth.externalauthmap': { + 'Meta': {'unique_together': "(('external_id', 'external_domain'),)", 'object_name': 'ExternalAuthMap'}, + 'dtcreated': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'dtsignup': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'external_credentials': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'external_domain': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'external_email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'external_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'external_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'internal_password': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True', 'null': 'True'}) + } + } + + complete_apps = ['external_auth'] \ No newline at end of file diff --git a/common/djangoapps/external_auth/migrations/__init__.py b/common/djangoapps/external_auth/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/external_auth/tests/test_shib.py b/common/djangoapps/external_auth/tests/test_shib.py new file mode 100644 index 0000000000..eb05b59afb --- /dev/null +++ b/common/djangoapps/external_auth/tests/test_shib.py @@ -0,0 +1,405 @@ +""" +Tests for Shibboleth Authentication +@jbau +""" +import unittest + +from django.conf import settings +from django.http import HttpResponseRedirect +from django.test.client import RequestFactory, Client as DjangoTestClient +from django.test.utils import override_settings +from django.core.urlresolvers import reverse +from django.contrib.auth.models import AnonymousUser, User +from django.contrib.sessions.backends.base import SessionBase +from django.utils.importlib import import_module + +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.inheritance import own_metadata +from xmodule.modulestore.django import modulestore + +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE + +from external_auth.models import ExternalAuthMap +from external_auth.views import shib_login, course_specific_login, course_specific_register + +from student.views import create_account, change_enrollment +from student.models import UserProfile, Registration, CourseEnrollment +from student.tests.factories import UserFactory + +#Shib is supposed to provide 'REMOTE_USER', 'givenName', 'sn', 'mail', 'Shib-Identity-Provider' +#attributes via request.META. We can count on 'Shib-Identity-Provider', and 'REMOTE_USER' being present +#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 ';' + + +def gen_all_identities(): + """ + A generator for all combinations of test inputs. + Each generated item is a dict that represents what a shib IDP + 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): + """ Helper function to return a dict of test identity """ + meta_dict = {'Shib-Identity-Provider': IDP, + 'REMOTE_USER': REMOTE_USER} + if mail is not None: + meta_dict['mail'] = mail + if given_name is not None: + meta_dict['givenName'] = given_name + if surname is not None: + meta_dict['sn'] = surname + return meta_dict + + for mail in MAILS: + for given_name in GIVENNAMES: + for surname in SNS: + yield _build_identity_dict(mail, given_name, surname) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE, SESSION_ENGINE='django.contrib.sessions.backends.cache') +class ShibSPTest(ModuleStoreTestCase): + """ + Tests for the Shibboleth SP, which communicates via request.META + (Apache environment variables set by mod_shib) + """ + request_factory = RequestFactory() + + def setUp(self): + self.store = modulestore() + + @unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True) + def test_exception_shib_login(self): + """ + Tests that we get the error page when there is no REMOTE_USER + or Shib-Identity-Provider in request.META + """ + no_remote_user_request = self.request_factory.get('/shib-login') + no_remote_user_request.META.update({'Shib-Identity-Provider': IDP}) + no_remote_user_response = shib_login(no_remote_user_request) + self.assertEqual(no_remote_user_response.status_code, 403) + self.assertIn("identity server did not return your ID information", no_remote_user_response.content) + + no_idp_request = self.request_factory.get('/shib-login') + no_idp_request.META.update({'REMOTE_USER': REMOTE_USER}) + no_idp_response = shib_login(no_idp_request) + self.assertEqual(no_idp_response.status_code, 403) + self.assertIn("identity server did not return your ID information", no_idp_response.content) + + + @unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True) + def test_shib_login(self): + """ + Tests that: + * shib credentials that match an existing ExternalAuthMap with a linked user logs the user in + * shib credentials that match an existing ExternalAuthMap without a linked user and also match the email + of an existing user without an existing ExternalAuthMap links the two and log the user in + * shib credentials that match an existing ExternalAuthMap without a linked user and also match the email + of an existing user that already has an ExternalAuthMap causes an error (403) + * shib credentials that do not match an existing ExternalAuthMap causes the registration form to appear + """ + + user_w_map = UserFactory.create(email='withmap@stanford.edu') + extauth = ExternalAuthMap(external_id='withmap@stanford.edu', + external_email='', + external_domain='shib:https://idp.stanford.edu/', + external_credentials="", + user=user_w_map) + user_wo_map = UserFactory.create(email='womap@stanford.edu') + user_w_map.save() + user_wo_map.save() + extauth.save() + + idps = ['https://idp.stanford.edu/', 'https://someother.idp.com/'] + remote_users = ['withmap@stanford.edu', 'womap@stanford.edu', 'testuser2@someother_idp.com'] + + for idp in idps: + for remote_user in remote_users: + request = self.request_factory.get('/shib-login') + request.session = import_module(settings.SESSION_ENGINE).SessionStore() # empty session + request.META.update({'Shib-Identity-Provider': idp, + 'REMOTE_USER': remote_user, + 'mail': remote_user}) + request.user = AnonymousUser() + response = shib_login(request) + if idp == "https://idp.stanford.edu/" and remote_user == 'withmap@stanford.edu': + self.assertIsInstance(response, HttpResponseRedirect) + self.assertEqual(request.user, user_w_map) + self.assertEqual(response['Location'], '/') + elif idp == "https://idp.stanford.edu/" and remote_user == 'womap@stanford.edu': + self.assertIsNotNone(ExternalAuthMap.objects.get(user=user_wo_map)) + self.assertIsInstance(response, HttpResponseRedirect) + self.assertEqual(request.user, user_wo_map) + self.assertEqual(response['Location'], '/') + elif idp == "https://someother.idp.com/" and remote_user in \ + ['withmap@stanford.edu', 'womap@stanford.edu']: + self.assertEqual(response.status_code, 403) + self.assertIn("You have already created an account using an external login", response.content) + else: + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Register for") + + @unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True) + def test_registration_form(self): + """ + Tests the registration form showing up with the proper parameters. + + Uses django test client for its session support + """ + for identity in gen_all_identities(): + client = DjangoTestClient() + # identity k/v pairs will show up in request.META + response = client.get(path='/shib-login/', data={}, follow=False, **identity) + + self.assertEquals(response.status_code, 200) + mail_input_HTML = '<input class="" id="email" type="email" name="email"' + if not identity.get('mail'): + self.assertContains(response, mail_input_HTML) + else: + 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) + else: + 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): + """ + 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 + request. + + Uses django test client for its session support + """ + for identity in gen_all_identities(): + #First we pop the registration form + client = DjangoTestClient() + response1 = client.get(path='/shib-login/', data={}, follow=False, **identity) + #Then we have the user answer the registration form + postvars = {'email': 'post_email@stanford.edu', + 'username': 'post_username', + 'password': 'post_password', + 'name': 'post_name', + 'terms_of_service': 'true', + 'honor_code': 'true'} + #use RequestFactory instead of TestClient here because we want access to request.user + request2 = self.request_factory.post('/create_account', data=postvars) + request2.session = client.session + request2.user = AnonymousUser() + response2 = create_account(request2) + + user = request2.user + mail = identity.get('mail') + #check that the created user has the right email, either taken from shib or user input + if mail: + self.assertEqual(user.email, mail) + self.assertEqual(list(User.objects.filter(email=postvars['email'])), []) + self.assertIsNotNone(User.objects.get(email=mail)) # get enforces only 1 such user + else: + self.assertEqual(user.email, postvars['email']) + self.assertEqual(list(User.objects.filter(email=mail)), []) + self.assertIsNotNone(User.objects.get(email=postvars['email'])) # get enforces only 1 such user + + #check that the created user profile has the right name, either taken from shib or user input + 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']) + else: + self.assertEqual(profile.name, request2.session['ExternalAuthMap'].external_name) + #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): + """ + Tests that the correct course specific login and registration urls work for shib + """ + course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') + + # Test for cases where course is found + for domain in ["", "shib:https://idp.stanford.edu/"]: + #set domains + course.enrollment_domain = domain + metadata = own_metadata(course) + metadata['enrollment_domain'] = domain + self.store.update_metadata(course.location.url(), metadata) + + #setting location to test that GET params get passed through + login_request = self.request_factory.get('/course_specific_login/MITx/999/Robot_Super_Course' + + '?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') + + login_response = course_specific_login(login_request, 'MITx/999/Robot_Super_Course') + reg_response = course_specific_register(login_request, 'MITx/999/Robot_Super_Course') + + if "shib" in domain: + self.assertIsInstance(login_response, HttpResponseRedirect) + self.assertEqual(login_response['Location'], + reverse('shib-login') + + '?course_id=MITx/999/Robot_Super_Course' + + '&enrollment_action=enroll') + self.assertIsInstance(login_response, HttpResponseRedirect) + self.assertEqual(reg_response['Location'], + reverse('shib-login') + + '?course_id=MITx/999/Robot_Super_Course' + + '&enrollment_action=enroll') + else: + self.assertIsInstance(login_response, HttpResponseRedirect) + self.assertEqual(login_response['Location'], + reverse('signin_user') + + '?course_id=MITx/999/Robot_Super_Course' + + '&enrollment_action=enroll') + self.assertIsInstance(login_response, HttpResponseRedirect) + self.assertEqual(reg_response['Location'], + reverse('register_user') + + '?course_id=MITx/999/Robot_Super_Course' + + '&enrollment_action=enroll') + + # Now test for non-existent course + #setting location to test that GET params get passed through + login_request = self.request_factory.get('/course_specific_login/DNE/DNE/DNE' + + '?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') + + login_response = course_specific_login(login_request, 'DNE/DNE/DNE') + reg_response = course_specific_register(login_request, 'DNE/DNE/DNE') + + self.assertIsInstance(login_response, HttpResponseRedirect) + self.assertEqual(login_response['Location'], + reverse('signin_user') + + '?course_id=DNE/DNE/DNE' + + '&enrollment_action=enroll') + self.assertIsInstance(login_response, HttpResponseRedirect) + self.assertEqual(reg_response['Location'], + reverse('register_user') + + '?course_id=DNE/DNE/DNE' + + '&enrollment_action=enroll') + + @unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True) + def test_enrollment_limit_by_domain(self): + """ + Tests that the enrollmentDomain setting is properly limiting enrollment to those who have + the proper external auth + """ + + #create 2 course, one with limited enrollment one without + shib_course = CourseFactory.create(org='Stanford', number='123', display_name='Shib Only') + shib_course.enrollment_domain = 'shib:https://idp.stanford.edu/' + metadata = own_metadata(shib_course) + metadata['enrollment_domain'] = shib_course.enrollment_domain + self.store.update_metadata(shib_course.location.url(), metadata) + + open_enroll_course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') + open_enroll_course.enrollment_domain = '' + metadata = own_metadata(open_enroll_course) + metadata['enrollment_domain'] = open_enroll_course.enrollment_domain + self.store.update_metadata(open_enroll_course.location.url(), metadata) + + # create 3 kinds of students, external_auth matching shib_course, external_auth not matching, no external auth + shib_student = UserFactory.create() + shib_student.save() + extauth = ExternalAuthMap(external_id='testuser@stanford.edu', + external_email='', + external_domain='shib:https://idp.stanford.edu/', + external_credentials="", + user=shib_student) + extauth.save() + + other_ext_student = UserFactory.create() + other_ext_student.username = "teststudent2" + other_ext_student.email = "teststudent2@other.edu" + other_ext_student.save() + extauth = ExternalAuthMap(external_id='testuser1@other.edu', + external_email='', + external_domain='shib:https://other.edu/', + external_credentials="", + user=other_ext_student) + extauth.save() + + int_student = UserFactory.create() + int_student.username = "teststudent3" + int_student.email = "teststudent3@gmail.com" + int_student.save() + + #Tests the two case for courses, limited and not + for course in [shib_course, open_enroll_course]: + for student in [shib_student, other_ext_student, int_student]: + request = self.request_factory.post('/change_enrollment') + request.POST.update({'enrollment_action': 'enroll', + 'course_id': course.id}) + request.user = student + response = change_enrollment(request) + #if course is not limited or student has correct shib extauth then enrollment should be allowed + if course is open_enroll_course or student is shib_student: + self.assertEqual(response.status_code, 200) + self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 1) + #clean up + CourseEnrollment.objects.filter(user=student, course_id=course.id).delete() + else: + self.assertEqual(response.status_code, 400) + self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 0) + + @unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True) + 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 + """ + if not settings.MITX_FEATURES.get('AUTH_USE_SHIB'): + return + + student = UserFactory.create() + extauth = ExternalAuthMap(external_id='testuser@stanford.edu', + external_email='', + external_domain='shib:https://idp.stanford.edu/', + external_credentials="", + internal_password="password", + user=student) + student.set_password("password") + student.save() + extauth.save() + + course = CourseFactory.create(org='Stanford', number='123', display_name='Shib Only') + course.enrollment_domain = 'shib:https://idp.stanford.edu/' + metadata = own_metadata(course) + metadata['enrollment_domain'] = course.enrollment_domain + self.store.update_metadata(course.location.url(), metadata) + + #use django test client for sessions and url processing + #no enrollment before trying + self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 0) + self.client.logout() + request_kwargs = {'path': '/shib-login/', + 'data': {'enrollment_action': 'enroll', 'course_id': course.id}, + '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/') + #now there is enrollment + self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 1) diff --git a/common/djangoapps/external_auth/views.py b/common/djangoapps/external_auth/views.py index 23b46aa803..93ab70debb 100644 --- a/common/djangoapps/external_auth/views.py +++ b/common/djangoapps/external_auth/views.py @@ -6,17 +6,24 @@ import re import string import fnmatch +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.models import User +from django.core.urlresolvers import reverse +from django.core.validators import validate_email +from django.core.exceptions import ValidationError + from student.models import UserProfile, TestCenterUser, TestCenterRegistration -from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpResponse, HttpResponseRedirect, HttpRequest from django.utils.http import urlquote from django.shortcuts import redirect +from django.utils.translation import ugettext as _ + from mitxmako.shortcuts import render_to_response, render_to_string try: from django.views.decorators.csrf import csrf_exempt @@ -40,6 +47,7 @@ from courseware.model_data import ModelDataCache from xmodule.modulestore.django import modulestore from xmodule.course_module import CourseDescriptor from xmodule.modulestore import Location +from xmodule.modulestore.exceptions import ItemNotFoundError log = logging.getLogger("mitx.external_auth") @@ -137,13 +145,48 @@ def external_login_or_signup(request, eamap.save() + log.info("External_Auth login_or_signup for %s : %s : %s : %s" % (external_domain, external_id, email, fullname)) internal_user = eamap.user if internal_user is None: - log.debug('No user for %s yet, doing signup' % eamap.external_email) - return signup(request, eamap) + if settings.MITX_FEATURES.get('AUTH_USE_SHIB'): + # if we are using shib, try to link accounts using email + try: + link_user = User.objects.get(email=eamap.external_email) + 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) + # 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 + # creds + failure_msg = _(dedent(""" + You have already created an account using an external login like WebAuth or Shibboleth. + Please contact %s for support """ + % getattr(settings, 'TECH_SUPPORT_EMAIL', 'techsupport@class.stanford.edu'))) + return default_render_failure(request, failure_msg) + except User.DoesNotExist: + log.info('SHIB: No user for %s yet, doing signup' % eamap.external_email) + return signup(request, eamap) + else: + log.info('No user for %s yet, doing signup' % eamap.external_email) + return signup(request, eamap) - uname = internal_user.username - user = authenticate(username=uname, password=eamap.internal_password) + # We trust shib's authentication, so no need to authenticate using the password again + if settings.MITX_FEATURES.get('AUTH_USE_SHIB'): + user = internal_user + # Assuming this 'AUTHENTICATION_BACKENDS' is set in settings, which I think is safe + if settings.AUTHENTICATION_BACKENDS: + auth_backend = settings.AUTHENTICATION_BACKENDS[0] + else: + auth_backend = 'django.contrib.auth.backends.ModelBackend' + user.backend = auth_backend + log.info('SHIB: Logging in linked user %s' % user.email) + else: + uname = internal_user.username + user = authenticate(username=uname, password=eamap.internal_password) if user is None: log.warning("External Auth Login failed for %s / %s" % (uname, eamap.internal_password)) @@ -154,10 +197,17 @@ def external_login_or_signup(request, # TODO: improve error page msg = 'Account not yet activated: please look for link in your email' return default_render_failure(request, msg) - login(request, user) request.session.set_expiry(0) - student_views.try_change_enrollment(request) + + # Now to try enrollment + # Need to special case Shibboleth here because it logs in via a GET. + # testing request.method for extra paranoia + if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and 'shib:' in external_domain 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) log.info("Login success - {0} ({1})".format(user.username, user.email)) if retfun is None: return redirect('/') @@ -188,14 +238,32 @@ def signup(request, eamap=None): context = {'has_extauth_info': True, 'show_signup_immediately': True, + 'extauth_id': eamap.external_id, 'extauth_email': eamap.external_email, 'extauth_username': username, 'extauth_name': eamap.external_name, + 'ask_for_tos': True, } - log.debug('Doing signup for %s' % eamap.external_email) + # Some openEdX instances can't have terms of service for shib users, like + # according to Stanford's Office of General Counsel + if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and settings.MITX_FEATURES.get('SHIB_DISABLE_TOS') and \ + ('shib' in eamap.external_domain): + context['ask_for_tos'] = False - return student_views.index(request, extra_context=context) + # detect if full name is blank and ask for it from user + context['ask_for_fullname'] = eamap.external_name.strip() == '' + + # validate provided mail and if it's not valid ask the user + try: + validate_email(eamap.external_email) + context['ask_for_email'] = False + except ValidationError: + context['ask_for_email'] = True + + log.info('EXTAUTH: Doing signup for %s' % eamap.external_id) + + return student_views.register_user(request, extra_context=context) # ----------------------------------------------------------------------------- @@ -304,6 +372,127 @@ def ssl_login(request): retfun=retfun) +# ----------------------------------------------------------------------------- +# Shibboleth (Stanford and others. Uses *Apache* environment variables) +# ----------------------------------------------------------------------------- +def shib_login(request): + """ + Uses Apache's REMOTE_USER environment variable as the external id. + This in turn typically uses EduPersonPrincipalName + http://www.incommonfederation.org/attributesummary.html#eduPersonPrincipal + but the configuration is in the shibboleth software. + """ + shib_error_msg = _(dedent( + """ + Your university identity server did not return your ID information to us. + Please try logging in again. (You may need to restart your browser.) + """)) + + if not request.META.get('REMOTE_USER'): + log.error("SHIB: no REMOTE_USER found in request.META") + return default_render_failure(request, shib_error_msg) + elif not request.META.get('Shib-Identity-Provider'): + log.error("SHIB: no Shib-Identity-Provider in request.META") + return default_render_failure(request, shib_error_msg) + 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']} + + #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') + + log.info("SHIB creds returned: %r" % shib) + + return external_login_or_signup(request, + external_id=shib['REMOTE_USER'], + external_domain="shib:" + shib['Shib-Identity-Provider'], + credentials=shib, + email=shib['mail'], + fullname=u'%s %s' % (shib['givenName'], shib['sn']), + ) + + +def make_shib_enrollment_request(request): + """ + Need this hack function because shibboleth logins don't happen over POST + but change_enrollment expects its request to be a POST, with + enrollment_action and course_id POST parameters. + """ + enroll_request = HttpRequest() + enroll_request.user = request.user + enroll_request.session = request.session + enroll_request.method = "POST" + + # copy() also makes GET and POST mutable + # See https://docs.djangoproject.com/en/dev/ref/request-response/#django.http.QueryDict.update + enroll_request.GET = request.GET.copy() + enroll_request.POST = request.POST.copy() + + # also have to copy these GET parameters over to POST + if "enrollment_action" not in enroll_request.POST and "enrollment_action" in enroll_request.GET: + enroll_request.POST.setdefault('enrollment_action', enroll_request.GET.get('enrollment_action')) + if "course_id" not in enroll_request.POST and "course_id" in enroll_request.GET: + enroll_request.POST.setdefault('course_id', enroll_request.GET.get('course_id')) + + return enroll_request + + +def course_specific_login(request, course_id): + """ + Dispatcher function for selecting the specific login method + 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) + + #now the dispatching conditionals. Only shib for now + if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and 'shib:' in course.enrollment_domain: + return redirect_with_querystring('shib-login', query_string) + + #Default fallthrough to normal signin page + return redirect_with_querystring('signin_user', query_string) + + +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) + + #now the dispatching conditionals. Only shib for now + if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and 'shib:' in course.enrollment_domain: + #shib-login takes care of both registration and login flows + return redirect_with_querystring('shib-login', query_string) + + #Default fallthrough to normal registration page + return redirect_with_querystring('register_user', query_string) + + +def redirect_with_querystring(view_name, query_string): + """ + Helper function to add query string to redirect views + """ + if query_string: + return redirect("%s?%s" % (reverse(view_name), query_string)) + return redirect(view_name) + + # ----------------------------------------------------------------------------- # OpenID Provider # ----------------------------------------------------------------------------- diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 4da7b9d789..faf9ae4cff 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -45,6 +45,8 @@ from collections import namedtuple from courseware.courses import get_courses, sort_by_announcement from courseware.access import has_access +from external_auth.models import ExternalAuthMap + from statsd import statsd from pytz import UTC @@ -226,7 +228,7 @@ def signin_user(request): @ensure_csrf_cookie -def register_user(request): +def register_user(request, extra_context={}): """ This view will display the non-modal registration form """ @@ -237,6 +239,8 @@ def register_user(request): 'course_id': request.GET.get('course_id'), 'enrollment_action': request.GET.get('enrollment_action') } + context.update(extra_context) + return render_to_response('register.html', context) @@ -278,9 +282,17 @@ def dashboard(request): # Get the 3 most recent news top_news = _get_news(top=3) if not settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False) else None + + # get info w.r.t ExternalAuthMap + external_auth_map = None + try: + external_auth_map = ExternalAuthMap.objects.get(user=user) + except ExternalAuthMap.DoesNotExist: + pass context = {'courses': courses, 'message': message, + 'external_auth_map': external_auth_map, 'staff_access': staff_access, 'errored_courses': errored_courses, 'show_courseware_links_for': show_courseware_links_for, @@ -567,15 +579,23 @@ def create_account(request, post_override=None): # if doing signup for an external authorization, then get email, password, name from the eamap # don't use the ones from the form, since the user could have hacked those + # unless originally we didn't get a valid email or name from the external auth DoExternalAuth = 'ExternalAuthMap' in request.session if DoExternalAuth: eamap = request.session['ExternalAuthMap'] - email = eamap.external_email - name = eamap.external_name + try: + validate_email(eamap.external_email) + email = eamap.external_email + except ValidationError: + email = post_vars.get('email', '') + if eamap.external_name.strip() == '': + name = post_vars.get('name', '') + else: + name = eamap.external_name password = eamap.internal_password post_vars = dict(post_vars.items()) post_vars.update(dict(email=email, name=name, password=password)) - log.debug('extauth test: post_vars = %s' % post_vars) + log.info('In create_account with external_auth: post_vars = %s' % post_vars) # Confirm we have a properly formed request for a in ['username', 'email', 'password', 'name']: @@ -589,17 +609,28 @@ def create_account(request, post_override=None): js['field'] = 'honor_code' return HttpResponse(json.dumps(js)) - if post_vars.get('terms_of_service', 'false') != u'true': - js['value'] = "You must accept the terms of service.".format(field=a) - js['field'] = 'terms_of_service' - 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) + + if not tos_not_required: + if post_vars.get('terms_of_service', 'false') != u'true': + js['value'] = "You must accept the terms of service.".format(field=a) + js['field'] = 'terms_of_service' + return HttpResponse(json.dumps(js)) # Confirm appropriate fields are there. # TODO: Check e-mail format is correct. # TODO: Confirm e-mail is not from a generic domain (mailinator, etc.)? Not sure if # this is a good idea # TODO: Check password is sane - for a in ['username', 'email', 'name', 'password', 'terms_of_service', 'honor_code']: + + required_post_vars = ['username', 'email', 'name', 'password', 'terms_of_service', 'honor_code'] + if tos_not_required: + required_post_vars = ['username', 'email', 'name', 'password', 'honor_code'] + + for a in required_post_vars: if len(post_vars[a]) < 2: error_str = {'username': 'Username must be minimum of two characters long.', 'email': 'A properly formatted e-mail is required.', @@ -661,19 +692,20 @@ def create_account(request, post_override=None): login(request, login_user) request.session.set_expiry(0) - try_change_enrollment(request) - if DoExternalAuth: eamap.user = login_user eamap.dtsignup = datetime.datetime.now(UTC) eamap.save() - log.debug('Updated ExternalAuthMap for %s to be %s' % (post_vars['username'], eamap)) + log.info("User registered with external_auth %s" % post_vars['username']) + log.info('Updated ExternalAuthMap for %s to be %s' % (post_vars['username'], eamap)) if settings.MITX_FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'): - log.debug('bypassing activation email') + log.info('bypassing activation email') login_user.is_active = True login_user.save() + try_change_enrollment(request) + statsd.increment("common.student.account_created") js = {'success': True} diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 945c3a3cfa..62ebe12a03 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -179,6 +179,8 @@ class CourseFields(object): checklists = List(scope=Scope.settings) info_sidebar_name = String(scope=Scope.settings, default='Course Handouts') show_timezone = Boolean(help="True if timezones should be shown on dates in the courseware", scope=Scope.settings, default=True) + enrollment_domain = String(help="External login method associated with user accounts allowed to register in course", + scope=Scope.settings) # An extra property is used rather than the wiki_slug/number because # there are courses that change the number for different runs. This allows diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index e25f44b939..ec90260928 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -14,6 +14,7 @@ from xmodule.modulestore import Location from xmodule.x_module import XModule, XModuleDescriptor from student.models import CourseEnrollmentAllowed +from external_auth.models import ExternalAuthMap from courseware.masquerade import is_masquerading_as_student from django.utils.timezone import UTC @@ -129,15 +130,33 @@ def _has_access_course_desc(user, course, action): def can_enroll(): """ - If the course has an enrollment period, check whether we are in it. + First check if restriction of enrollment by login method is enabled, both + globally and by the course. + If it is, then the user must pass the criterion set by the course, e.g. that ExternalAuthMap + was set by 'shib:https://idp.stanford.edu/", in addition to requirements below. + Rest of requirements: + Enrollment can only happen in the course enrollment period, if one exists. + or + + (CourseEnrollmentAllowed always overrides) (staff can always enroll) """ + # if using registration method to restrict (say shibboleth) + if settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain: + if user is not None and user.is_authenticated() and \ + ExternalAuthMap.objects.filter(user=user, external_domain=course.enrollment_domain): + debug("Allow: external_auth of " + course.enrollment_domain) + reg_method_ok = True + else: + reg_method_ok = False + else: + reg_method_ok = True #if not using this access check, it's always OK. now = datetime.now(UTC()) start = course.enrollment_start end = course.enrollment_end - if (start is None or now > start) and (end is None or now < end): + if reg_method_ok and (start is None or now > start) and (end is None or now < end): # in enrollment period, so any user is allowed to enroll. debug("Allow: in enrollment period") return True diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py index f93fa0d659..a1cd12ae24 100644 --- a/lms/djangoapps/courseware/tests/test_access.py +++ b/lms/djangoapps/courseware/tests/test_access.py @@ -81,7 +81,7 @@ class AccessTestCase(TestCase): u = Mock() yesterday = datetime.datetime.now(UTC()) - datetime.timedelta(days=1) tomorrow = datetime.datetime.now(UTC()) + datetime.timedelta(days=1) - c = Mock(enrollment_start=yesterday, enrollment_end=tomorrow) + c = Mock(enrollment_start=yesterday, enrollment_end=tomorrow, enrollment_domain='') # User can enroll if it is between the start and end dates self.assertTrue(access._has_access_course_desc(u, c, 'enroll')) @@ -91,7 +91,7 @@ class AccessTestCase(TestCase): u = Mock(email='test@edx.org', is_staff=False) u.is_authenticated.return_value = True - c = Mock(enrollment_start=tomorrow, enrollment_end=tomorrow, id='edX/test/2012_Fall') + c = Mock(enrollment_start=tomorrow, enrollment_end=tomorrow, id='edX/test/2012_Fall', enrollment_domain='') allowed = CourseEnrollmentAllowedFactory(email=u.email, course_id=c.id) @@ -101,7 +101,7 @@ class AccessTestCase(TestCase): u = Mock(email='test@edx.org', is_staff=True) u.is_authenticated.return_value = True - c = Mock(enrollment_start=tomorrow, enrollment_end=tomorrow, id='edX/test/Whenever') + c = Mock(enrollment_start=tomorrow, enrollment_end=tomorrow, id='edX/test/Whenever', enrollment_domain='') self.assertTrue(access._has_access_course_desc(u, c, 'enroll')) # TODO: diff --git a/lms/envs/aws.py b/lms/envs/aws.py index c8c49c2b1e..a237788163 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -138,6 +138,10 @@ MKTG_URL_LINK_MAP.update(ENV_TOKENS.get('MKTG_URL_LINK_MAP', {})) #Timezone overrides TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE) +#Additional installed apps +for app in ENV_TOKENS.get('ADDL_INSTALLED_APPS', []): + INSTALLED_APPS += (app,) + for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items(): MITX_FEATURES[feature] = value diff --git a/lms/envs/common.py b/lms/envs/common.py index edca621ea4..eff174b3d7 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -91,6 +91,14 @@ MITX_FEATURES = { 'AUTH_USE_OPENID': False, 'AUTH_USE_MIT_CERTIFICATES': False, 'AUTH_USE_OPENID_PROVIDER': False, + 'AUTH_USE_SHIB': False, + + # This flag disables the requirement of having to agree to the TOS for users registering + # with Shib. Feature was requested by Stanford's office of general counsel + 'SHIB_DISABLE_TOS': False, + + # Enables ability to restrict enrollment in specific courses by the user account login method + 'RESTRICT_ENROLL_BY_REG_METHOD': False, # analytics experiments 'ENABLE_INSTRUCTOR_ANALYTICS': False, @@ -699,6 +707,10 @@ INSTALLED_APPS = ( 'licenses', 'course_groups', + # External auth (OpenID, shib) + 'external_auth', + 'django_openid_auth', + #For the wiki 'wiki', # The new django-wiki from benjaoming 'django_notify', diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 17bce93991..b1519b77bc 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -232,6 +232,9 @@ FILE_UPLOAD_HANDLERS = ( 'django.core.files.uploadhandler.TemporaryFileUploadHandler', ) +MITX_FEATURES['AUTH_USE_SHIB'] = True +MITX_FEATURES['RESTRICT_ENROLL_BY_REG_METHOD'] = True + ########################### PIPELINE ################################# PIPELINE_SASS_ARGUMENTS = '--debug-info --require {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.format(proj_dir=PROJECT_ROOT) diff --git a/lms/envs/test.py b/lms/envs/test.py index 3ccfa24014..e9b683487e 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -137,14 +137,16 @@ SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' MITX_FEATURES['AUTH_USE_OPENID'] = True MITX_FEATURES['AUTH_USE_OPENID_PROVIDER'] = True +################################## SHIB ####################################### +MITX_FEATURES['AUTH_USE_SHIB'] = True +MITX_FEATURES['SHIB_DISABLE_TOS'] = True +MITX_FEATURES['RESTRICT_ENROLL_BY_REG_METHOD'] = True + OPENID_CREATE_USERS = False OPENID_UPDATE_DETAILS_FROM_SREG = True OPENID_USE_AS_ADMIN_LOGIN = False OPENID_PROVIDER_TRUSTED_ROOTS = ['*'] -INSTALLED_APPS += ('external_auth',) -INSTALLED_APPS += ('django_openid_auth',) - ################################# CELERY ###################################### CELERY_ALWAYS_EAGER = True diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index cc4b2ec317..15317de207 100644 --- a/lms/templates/courseware/course_about.html +++ b/lms/templates/courseware/course_about.html @@ -24,6 +24,26 @@ event.preventDefault(); }); + ## making the conditional around this entire JS block for sanity + %if settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain: + $('#class_enroll_form').on('ajax:complete', function(event, xhr) { + if(xhr.status == 200) { + location.href = "${reverse('dashboard')}"; + } else if (xhr.status == 403) { + location.href = "${reverse('course-specific-register', args=[course.id])}?course_id=${course.id}&enrollment_action=enroll"; + } else if (xhr.status == 400) { //This means the user did not have permission + $('#register_error').html('This course has restricted enrollment. Sorry, you do not have permission to enroll.<br />' + + 'You may need to log out and re-login with a university account, such as WebAuth' + ).css("display", "block"); + } else { + $('#register_error').html( + (xhr.responseText ? xhr.responseText : 'An error occurred. Please try again later.') + ).css("display", "block"); + } + }); + + %else: + $('#class_enroll_form').on('ajax:complete', function(event, xhr) { if(xhr.status == 200) { location.href = "${reverse('dashboard')}"; @@ -35,13 +55,16 @@ ).css("display", "block"); } }); + + %endif + + })(this) </script> <script src="${static.url('js/course_info.js')}"></script> </%block> - <%block name="title"><title>About ${course.number}
@@ -92,7 +115,7 @@ - +
diff --git a/lms/templates/extauth_failure.html b/lms/templates/extauth_failure.html index fa53ab1084..330c63e604 100644 --- a/lms/templates/extauth_failure.html +++ b/lms/templates/extauth_failure.html @@ -2,10 +2,10 @@ "http://www.w3.org/TR/html4/strict.dtd"> - OpenID failed + External Authentication failed -

OpenID failed

+

External Authentication failed

${message}

diff --git a/lms/templates/navigation.html b/lms/templates/navigation.html index 190a58f691..a26e1ca367 100644 --- a/lms/templates/navigation.html +++ b/lms/templates/navigation.html @@ -95,16 +95,26 @@ site_status_msg = get_site_status_msg(course_id) % endif % if not settings.MITX_FEATURES['DISABLE_LOGIN_BUTTON']: - + % if course and settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain: + + % else: + + % endif % endif diff --git a/lms/templates/register.html b/lms/templates/register.html index 73a6df9319..1a42d402e5 100644 --- a/lms/templates/register.html +++ b/lms/templates/register.html @@ -136,16 +136,37 @@ % else:
-

Welcome ${extauth_email}

+

Welcome ${extauth_id}

Enter a public username:

    + + % if ask_for_email: + +
  1. + + +
  2. + + % endif +
  3. Will be shown in any discussions or forums you participate in
  4. + + % if ask_for_fullname: + +
  5. + + + Needed for any certificates you may earn (cannot be changed later) +
  6. + + % endif +
% endif @@ -210,11 +231,16 @@
  1. + + % if has_extauth_info is UNDEFINED or ask_for_tos : +
    + % endif +
    <% @@ -246,6 +272,8 @@

    Registration Help

    + % if has_extauth_info is UNDEFINED: +

    Already registered?

    @@ -254,6 +282,8 @@

    + + % endif ## TODO: Use a %block tag or something to allow themes to ## override in a more generalizable fashion. diff --git a/lms/templates/signup_modal.html b/lms/templates/signup_modal.html index a68e36e902..9c1a868e2d 100644 --- a/lms/templates/signup_modal.html +++ b/lms/templates/signup_modal.html @@ -32,11 +32,23 @@ % else: -

    Welcome ${extauth_email}


    +

    Welcome ${extauth_id}


    Enter a public username:

    - + - + + + % if ask_for_email: + + + % endif + + + % if ask_for_fullname: + + + % endif + % endif
    diff --git a/lms/urls.py b/lms/urls.py index 80f1224837..f6978f5f7b 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -364,6 +364,21 @@ if settings.MITX_FEATURES.get('AUTH_USE_OPENID'): url(r'^openid/logo.gif$', 'django_openid_auth.views.logo', name='openid-logo'), ) +if settings.MITX_FEATURES.get('AUTH_USE_SHIB'): + urlpatterns += ( + url(r'^shib-login/$', 'external_auth.views.shib_login', name='shib-login'), + ) + +if settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD'): + urlpatterns += ( + url(r'^course_specific_login/(?P[^/]+/[^/]+/[^/]+)/$', + 'external_auth.views.course_specific_login', name='course-specific-login'), + url(r'^course_specific_register/(?P[^/]+/[^/]+/[^/]+)/$', + 'external_auth.views.course_specific_register', name='course-specific-register'), + + ) + + if settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): urlpatterns += ( url(r'^openid/provider/login/$', 'external_auth.views.provider_login', name='openid-provider-login'), diff --git a/lms/wsgi_apache_lms.py b/lms/wsgi_apache_lms.py new file mode 100644 index 0000000000..0f9950ca41 --- /dev/null +++ b/lms/wsgi_apache_lms.py @@ -0,0 +1,15 @@ +import os + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "lms.envs.aws") +os.environ.setdefault("SERVICE_VARIANT", "lms") + +# This application object is used by the development server +# as well as any WSGI server configured to use this file. +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() + +from django.conf import settings +from xmodule.modulestore.django import modulestore + +for store_name in settings.MODULESTORE: + modulestore(store_name)