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, "
${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 %block> % if not settings.MITX_FEATURES['DISABLE_LOGIN_BUTTON']: -@@ -254,6 +282,8 @@
Welcome ${extauth_email}
Welcome ${extauth_id}
Enter a public username:
- + - + + + % if ask_for_email: + + + % endif + + + % if ask_for_fullname: + + + % endif + % endif