diff --git a/.gitignore b/.gitignore index 861ceec67f..1c2845e175 100644 --- a/.gitignore +++ b/.gitignore @@ -91,6 +91,9 @@ logs chromedriver.log ghostdriver.log +### Celery artifacts ### +celerybeat-schedule + ### Unknown artifacts database.sqlite courseware/static/js/mathjax/* diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index 9ddfea424c..06abbe7706 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -196,8 +196,9 @@ def auth_pipeline_urls(auth_entry, redirect_url=None): return {} return { - provider.NAME: third_party_auth.pipeline.get_login_url(provider.NAME, auth_entry, redirect_url=redirect_url) - for provider in third_party_auth.provider.Registry.enabled() + provider.provider_id: third_party_auth.pipeline.get_login_url( + provider.provider_id, auth_entry, redirect_url=redirect_url + ) for provider in third_party_auth.provider.Registry.enabled() } diff --git a/common/djangoapps/student/tests/test_login_registration_forms.py b/common/djangoapps/student/tests/test_login_registration_forms.py index 3ebafb2358..3fa383b0a5 100644 --- a/common/djangoapps/student/tests/test_login_registration_forms.py +++ b/common/djangoapps/student/tests/test_login_registration_forms.py @@ -11,12 +11,12 @@ from django.core.urlresolvers import reverse from util.testing import UrlResetMixin from xmodule.modulestore.tests.factories import CourseFactory from student.tests.factories import CourseModeFactory +from third_party_auth.tests.testutil import ThirdPartyAuthTestMixin from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -# This relies on third party auth being enabled and configured -# in the test settings. See the setting `THIRD_PARTY_AUTH` -# and the feature flag `ENABLE_THIRD_PARTY_AUTH` +# This relies on third party auth being enabled in the test +# settings with the feature flag `ENABLE_THIRD_PARTY_AUTH` THIRD_PARTY_AUTH_BACKENDS = ["google-oauth2", "facebook"] THIRD_PARTY_AUTH_PROVIDERS = ["Google", "Facebook"] @@ -40,7 +40,7 @@ def _finish_auth_url(params): @ddt.ddt @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class LoginFormTest(UrlResetMixin, ModuleStoreTestCase): +class LoginFormTest(ThirdPartyAuthTestMixin, UrlResetMixin, ModuleStoreTestCase): """Test rendering of the login form. """ @patch.dict(settings.FEATURES, {"ENABLE_COMBINED_LOGIN_REGISTRATION": False}) def setUp(self): @@ -50,6 +50,8 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase): self.course = CourseFactory.create() self.course_id = unicode(self.course.id) self.courseware_url = reverse("courseware", args=[self.course_id]) + self.configure_google_provider(enabled=True) + self.configure_facebook_provider(enabled=True) @patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False}) @ddt.data(THIRD_PARTY_AUTH_PROVIDERS) @@ -148,7 +150,7 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase): @ddt.ddt @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class RegisterFormTest(UrlResetMixin, ModuleStoreTestCase): +class RegisterFormTest(ThirdPartyAuthTestMixin, UrlResetMixin, ModuleStoreTestCase): """Test rendering of the registration form. """ @patch.dict(settings.FEATURES, {"ENABLE_COMBINED_LOGIN_REGISTRATION": False}) def setUp(self): @@ -157,6 +159,8 @@ class RegisterFormTest(UrlResetMixin, ModuleStoreTestCase): self.url = reverse("register_user") self.course = CourseFactory.create() self.course_id = unicode(self.course.id) + self.configure_google_provider(enabled=True) + self.configure_facebook_provider(enabled=True) @patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False}) @ddt.data(*THIRD_PARTY_AUTH_PROVIDERS) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index bd2e9bc58d..1420aa5432 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -368,7 +368,7 @@ def signin_user(request): for msg in messages.get_messages(request): if msg.extra_tags.split()[0] == "social-auth": # msg may or may not be translated. Try translating [again] in case we are able to: - third_party_auth_error = _(msg) # pylint: disable=translation-of-non-string + third_party_auth_error = _(unicode(msg)) # pylint: disable=translation-of-non-string break context = { @@ -424,10 +424,10 @@ def register_user(request, extra_context=None): # selected provider. if third_party_auth.is_enabled() and pipeline.running(request): running_pipeline = pipeline.get(request) - current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend')) + current_provider = provider.Registry.get_from_pipeline(running_pipeline) overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs')) overrides['running_pipeline'] = running_pipeline - overrides['selected_provider'] = current_provider.NAME + overrides['selected_provider'] = current_provider.name context.update(overrides) return render_to_response('register.html', context) @@ -952,10 +952,11 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un running_pipeline = pipeline.get(request) username = running_pipeline['kwargs'].get('username') backend_name = running_pipeline['backend'] - requested_provider = provider.Registry.get_by_backend_name(backend_name) + third_party_uid = running_pipeline['kwargs']['uid'] + requested_provider = provider.Registry.get_from_pipeline(running_pipeline) try: - user = pipeline.get_authenticated_user(username, backend_name) + user = pipeline.get_authenticated_user(requested_provider, username, third_party_uid) third_party_auth_successful = True except User.DoesNotExist: AUDIT_LOG.warning( @@ -963,12 +964,12 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un username=username, backend_name=backend_name)) return HttpResponse( _("You've successfully logged into your {provider_name} account, but this account isn't linked with an {platform_name} account yet.").format( - platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.NAME + platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.name ) + "

" + _("Use your {platform_name} username and password to log into {platform_name} below, " "and then link your {platform_name} account with {provider_name} from your dashboard.").format( - platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.NAME + platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.name ) + "

" + _("If you don't have an {platform_name} account yet, click Register Now at the top of the page.").format( @@ -1497,6 +1498,13 @@ def create_account_with_params(request, params): dog_stats_api.increment("common.student.account_created") + # If the user is registering via 3rd party auth, track which provider they use + third_party_provider = None + running_pipeline = None + if third_party_auth.is_enabled() and pipeline.running(request): + running_pipeline = pipeline.get(request) + third_party_provider = provider.Registry.get_from_pipeline(running_pipeline) + # Track the user's registration if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'): tracking_context = tracker.get_tracker().resolve_context() @@ -1505,20 +1513,13 @@ def create_account_with_params(request, params): 'username': user.username, }) - # If the user is registering via 3rd party auth, track which provider they use - provider_name = None - if third_party_auth.is_enabled() and pipeline.running(request): - running_pipeline = pipeline.get(request) - current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend')) - provider_name = current_provider.NAME - analytics.track( user.id, "edx.bi.user.account.registered", { 'category': 'conversion', 'label': params.get('course_id'), - 'provider': provider_name + 'provider': third_party_provider.name if third_party_provider else None }, context={ 'Google Analytics': { @@ -1535,6 +1536,7 @@ def create_account_with_params(request, params): # 2. Random user generation for other forms of testing. # 3. External auth bypassing activation. # 4. Have the platform configured to not require e-mail activation. + # 5. Registering a new user using a trusted third party provider (with skip_email_verification=True) # # Note that this feature is only tested as a flag set one way or # the other for *new* systems. we need to be careful about @@ -1543,7 +1545,11 @@ def create_account_with_params(request, params): send_email = ( not settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) and not settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') and - not (do_external_auth and settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH')) + not (do_external_auth and settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH')) and + not ( + third_party_provider and third_party_provider.skip_email_verification and + user.email == running_pipeline['kwargs'].get('details', {}).get('email') + ) ) if send_email: context = { diff --git a/common/djangoapps/third_party_auth/admin.py b/common/djangoapps/third_party_auth/admin.py new file mode 100644 index 0000000000..8495ef3a2b --- /dev/null +++ b/common/djangoapps/third_party_auth/admin.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +""" +Admin site configuration for third party authentication +""" + +from django.contrib import admin + +from config_models.admin import ConfigurationModelAdmin, KeyedConfigurationModelAdmin +from .models import OAuth2ProviderConfig, SAMLProviderConfig, SAMLConfiguration, SAMLProviderData +from .tasks import fetch_saml_metadata + +admin.site.register(OAuth2ProviderConfig, KeyedConfigurationModelAdmin) + + +class SAMLProviderConfigAdmin(KeyedConfigurationModelAdmin): + """ Django Admin class for SAMLProviderConfig """ + def get_list_display(self, request): + """ Don't show every single field in the admin change list """ + return ( + 'name', 'enabled', 'backend_name', 'entity_id', 'metadata_source', + 'has_data', 'icon_class', 'change_date', 'changed_by', 'edit_link' + ) + + def has_data(self, inst): + """ Do we have cached metadata for this SAML provider? """ + if not inst.is_active: + return None # N/A + data = SAMLProviderData.current(inst.entity_id) + return bool(data and data.is_valid()) + has_data.short_description = u'Metadata Ready' + has_data.boolean = True + + def save_model(self, request, obj, form, change): + """ + Post save: Queue an asynchronous metadata fetch to update SAMLProviderData. + We only want to do this for manual edits done using the admin interface. + + Note: This only works if the celery worker and the app worker are using the + same 'configuration' cache. + """ + super(SAMLProviderConfigAdmin, self).save_model(request, obj, form, change) + fetch_saml_metadata.apply_async((), countdown=2) + +admin.site.register(SAMLProviderConfig, SAMLProviderConfigAdmin) + + +class SAMLConfigurationAdmin(ConfigurationModelAdmin): + """ Django Admin class for SAMLConfiguration """ + def get_list_display(self, request): + """ Shorten the public/private keys in the change view """ + return ( + 'change_date', 'changed_by', 'enabled', 'entity_id', + 'org_info_str', 'key_summary', + ) + + def key_summary(self, inst): + """ Short summary of the key pairs configured """ + if not inst.public_key or not inst.private_key: + return u'Key pair incomplete/missing' + pub1, pub2 = inst.public_key[0:10], inst.public_key[-10:] + priv1, priv2 = inst.private_key[0:10], inst.private_key[-10:] + return u'Public: {}…{}
Private: {}…{}'.format(pub1, pub2, priv1, priv2) + key_summary.allow_tags = True + +admin.site.register(SAMLConfiguration, SAMLConfigurationAdmin) + + +class SAMLProviderDataAdmin(admin.ModelAdmin): + """ Django Admin class for SAMLProviderData (Read Only) """ + list_display = ('entity_id', 'is_valid', 'fetched_at', 'expires_at', 'sso_url') + readonly_fields = ('is_valid', ) + + def get_readonly_fields(self, request, obj=None): + if obj: # editing an existing object + return self.model._meta.get_all_field_names() # pylint: disable=protected-access + return self.readonly_fields + +admin.site.register(SAMLProviderData, SAMLProviderDataAdmin) diff --git a/common/djangoapps/third_party_auth/dummy.py b/common/djangoapps/third_party_auth/dummy.py index 4c2aa2dc4f..6bd8f58c4b 100644 --- a/common/djangoapps/third_party_auth/dummy.py +++ b/common/djangoapps/third_party_auth/dummy.py @@ -1,13 +1,11 @@ """ -DummyProvider: A fake Third Party Auth provider for testing & development purposes. +DummyBackend: A fake Third Party Auth provider for testing & development purposes. """ -from social.backends.base import BaseAuth +from social.backends.oauth import BaseOAuth2 from social.exceptions import AuthFailed -from .provider import BaseProvider - -class DummyBackend(BaseAuth): # pylint: disable=abstract-method +class DummyBackend(BaseOAuth2): # pylint: disable=abstract-method """ python-social-auth backend that doesn't actually go to any third party site """ @@ -47,12 +45,3 @@ class DummyBackend(BaseAuth): # pylint: disable=abstract-method kwargs.update({'response': response, 'backend': self}) return self.strategy.authenticate(*args, **kwargs) - - -class DummyProvider(BaseProvider): - """ Dummy Provider for testing and development """ - - BACKEND_CLASS = DummyBackend - ICON_CLASS = 'fa-cube' - NAME = 'Dummy' - SETTINGS = {} diff --git a/common/djangoapps/third_party_auth/management/__init__.py b/common/djangoapps/third_party_auth/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/third_party_auth/management/commands/__init__.py b/common/djangoapps/third_party_auth/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/third_party_auth/management/commands/saml.py b/common/djangoapps/third_party_auth/management/commands/saml.py new file mode 100644 index 0000000000..01918157ae --- /dev/null +++ b/common/djangoapps/third_party_auth/management/commands/saml.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +""" +Management commands for third_party_auth +""" +from django.core.management.base import BaseCommand, CommandError +import logging +from third_party_auth.models import SAMLConfiguration +from third_party_auth.tasks import fetch_saml_metadata + + +class Command(BaseCommand): + """ manage.py commands to manage SAML/Shibboleth SSO """ + help = '''Configure/maintain/update SAML-based SSO''' + + def handle(self, *args, **options): + if len(args) != 1: + raise CommandError("saml requires one argument: pull") + + if not SAMLConfiguration.is_enabled(): + raise CommandError("SAML support is disabled via SAMLConfiguration.") + + subcommand = args[0] + + if subcommand == "pull": + log_handler = logging.StreamHandler(self.stdout) + log_handler.setLevel(logging.DEBUG) + log = logging.getLogger('third_party_auth.tasks') + log.propagate = False + log.addHandler(log_handler) + num_changed, num_failed, num_total = fetch_saml_metadata() + self.stdout.write( + "\nDone. Fetched {num_total} total. {num_changed} were updated and {num_failed} failed.\n".format( + num_changed=num_changed, num_failed=num_failed, num_total=num_total + ) + ) + else: + raise CommandError("Unknown argment: {}".format(subcommand)) diff --git a/common/djangoapps/third_party_auth/migrations/0001_initial.py b/common/djangoapps/third_party_auth/migrations/0001_initial.py new file mode 100644 index 0000000000..d4a13a6dc0 --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0001_initial.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as 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 'OAuth2ProviderConfig' + db.create_table('third_party_auth_oauth2providerconfig', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)), + ('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('icon_class', self.gf('django.db.models.fields.CharField')(default='fa-sign-in', max_length=50)), + ('name', self.gf('django.db.models.fields.CharField')(max_length=50)), + ('backend_name', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)), + ('key', self.gf('django.db.models.fields.TextField')(blank=True)), + ('secret', self.gf('django.db.models.fields.TextField')(blank=True)), + ('other_settings', self.gf('django.db.models.fields.TextField')(blank=True)), + )) + db.send_create_signal('third_party_auth', ['OAuth2ProviderConfig']) + + # Adding model 'SAMLProviderConfig' + db.create_table('third_party_auth_samlproviderconfig', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)), + ('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('icon_class', self.gf('django.db.models.fields.CharField')(default='fa-sign-in', max_length=50)), + ('name', self.gf('django.db.models.fields.CharField')(max_length=50)), + ('backend_name', self.gf('django.db.models.fields.CharField')(default='tpa-saml', max_length=50)), + ('idp_slug', self.gf('django.db.models.fields.SlugField')(max_length=30)), + ('entity_id', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('metadata_source', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('attr_user_permanent_id', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('attr_full_name', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('attr_first_name', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('attr_last_name', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('attr_username', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('attr_email', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('other_settings', self.gf('django.db.models.fields.TextField')(blank=True)), + )) + db.send_create_signal('third_party_auth', ['SAMLProviderConfig']) + + # Adding model 'SAMLConfiguration' + db.create_table('third_party_auth_samlconfiguration', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)), + ('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('private_key', self.gf('django.db.models.fields.TextField')()), + ('public_key', self.gf('django.db.models.fields.TextField')()), + ('entity_id', self.gf('django.db.models.fields.CharField')(default='http://saml.example.com', max_length=255)), + ('org_info_str', self.gf('django.db.models.fields.TextField')(default='{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}')), + ('other_config_str', self.gf('django.db.models.fields.TextField')(default='{\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\n}')), + )) + db.send_create_signal('third_party_auth', ['SAMLConfiguration']) + + # Adding model 'SAMLProviderData' + db.create_table('third_party_auth_samlproviderdata', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('fetched_at', self.gf('django.db.models.fields.DateTimeField')(db_index=True)), + ('expires_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), + ('entity_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('sso_url', self.gf('django.db.models.fields.URLField')(max_length=200)), + ('public_key', self.gf('django.db.models.fields.TextField')()), + )) + db.send_create_signal('third_party_auth', ['SAMLProviderData']) + + + def backwards(self, orm): + # Deleting model 'OAuth2ProviderConfig' + db.delete_table('third_party_auth_oauth2providerconfig') + + # Deleting model 'SAMLProviderConfig' + db.delete_table('third_party_auth_samlproviderconfig') + + # Deleting model 'SAMLConfiguration' + db.delete_table('third_party_auth_samlconfiguration') + + # Deleting model 'SAMLProviderData' + db.delete_table('third_party_auth_samlproviderdata') + + + 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'}) + }, + 'third_party_auth.oauth2providerconfig': { + 'Meta': {'object_name': 'OAuth2ProviderConfig'}, + 'backend_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'secret': ('django.db.models.fields.TextField', [], {'blank': 'True'}) + }, + 'third_party_auth.samlconfiguration': { + 'Meta': {'object_name': 'SAMLConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'entity_id': ('django.db.models.fields.CharField', [], {'default': "'http://saml.example.com'", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'org_info_str': ('django.db.models.fields.TextField', [], {'default': '\'{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}\''}), + 'other_config_str': ('django.db.models.fields.TextField', [], {'default': '\'{\\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\\n}\''}), + 'private_key': ('django.db.models.fields.TextField', [], {}), + 'public_key': ('django.db.models.fields.TextField', [], {}) + }, + 'third_party_auth.samlproviderconfig': { + 'Meta': {'object_name': 'SAMLProviderConfig'}, + 'attr_email': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_first_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_full_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_last_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_user_permanent_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_username': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'backend_name': ('django.db.models.fields.CharField', [], {'default': "'tpa-saml'", 'max_length': '50'}), + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'idp_slug': ('django.db.models.fields.SlugField', [], {'max_length': '30'}), + 'metadata_source': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}) + }, + 'third_party_auth.samlproviderdata': { + 'Meta': {'ordering': "('-fetched_at',)", 'object_name': 'SAMLProviderData'}, + 'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'expires_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'fetched_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'public_key': ('django.db.models.fields.TextField', [], {}), + 'sso_url': ('django.db.models.fields.URLField', [], {'max_length': '200'}) + } + } + + complete_apps = ['third_party_auth'] \ No newline at end of file diff --git a/common/djangoapps/third_party_auth/migrations/0002_convert_settings.py b/common/djangoapps/third_party_auth/migrations/0002_convert_settings.py new file mode 100644 index 0000000000..a5c38bca81 --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0002_convert_settings.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +from django.conf import settings +import json +from south.v2 import DataMigration + + +class Migration(DataMigration): + + def forwards(self, orm): + """ Convert from the THIRD_PARTY_AUTH setting to OAuth2ProviderConfig """ + tpa = getattr(settings, 'THIRD_PARTY_AUTH_OLD_CONFIG', {}) + if tpa and not any(orm.OAuth2ProviderConfig.objects.all()): + print("Migrating third party auth config to OAuth2ProviderConfig") + providers = ( + # Name, backend, icon, prefix + ('Google', 'google-oauth2', 'fa-google-plus', 'SOCIAL_AUTH_GOOGLE_OAUTH2_'), + ('LinkedIn', 'linkedin-oauth2', 'fa-linkedin', 'SOCIAL_AUTH_LINKEDIN_OAUTH2_'), + ('Facebook', 'facebook', 'fa-facebook', 'SOCIAL_AUTH_FACEBOOK_'), + ) + for name, backend, icon, prefix in providers: + if name in tpa: + conf = tpa[name] + conf = {key.replace(prefix, ''): val for key, val in conf.items()} + key = conf.pop('KEY', '') + secret = conf.pop('SECRET', '') + orm.OAuth2ProviderConfig.objects.create( + enabled=True, + name=name, + backend_name=backend, + icon_class=icon, + key=key, + secret=secret, + other_settings=json.dumps(conf), + changed_by=None, + ) + print( + "Done. Make changes via /admin/third_party_auth/oauth2providerconfig/ " + "from now on. You can remove THIRD_PARTY_AUTH from ~/lms.auth.json" + ) + else: + print("Not migrating third party auth config to OAuth2ProviderConfig.") + + def backwards(self, orm): + """ No backwards migration necessary """ + pass + + 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'}) + }, + 'third_party_auth.oauth2providerconfig': { + 'Meta': {'object_name': 'OAuth2ProviderConfig'}, + 'backend_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'secret': ('django.db.models.fields.TextField', [], {'blank': 'True'}) + }, + 'third_party_auth.samlconfiguration': { + 'Meta': {'object_name': 'SAMLConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'entity_id': ('django.db.models.fields.CharField', [], {'default': "'http://saml.example.com'", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'org_info_str': ('django.db.models.fields.TextField', [], {'default': '\'{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}\''}), + 'other_config_str': ('django.db.models.fields.TextField', [], {'default': '\'{\\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\\n}\''}), + 'private_key': ('django.db.models.fields.TextField', [], {}), + 'public_key': ('django.db.models.fields.TextField', [], {}) + }, + 'third_party_auth.samlproviderconfig': { + 'Meta': {'object_name': 'SAMLProviderConfig'}, + 'attr_email': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_first_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_full_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_last_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_user_permanent_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_username': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'backend_name': ('django.db.models.fields.CharField', [], {'default': "'tpa-saml'", 'max_length': '50'}), + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'idp_slug': ('django.db.models.fields.SlugField', [], {'max_length': '30'}), + 'metadata_source': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}) + }, + 'third_party_auth.samlproviderdata': { + 'Meta': {'ordering': "('-fetched_at',)", 'object_name': 'SAMLProviderData'}, + 'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'expires_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'fetched_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'public_key': ('django.db.models.fields.TextField', [], {}), + 'sso_url': ('django.db.models.fields.URLField', [], {'max_length': '200'}) + } + } + + complete_apps = ['third_party_auth'] + symmetrical = True diff --git a/common/djangoapps/third_party_auth/migrations/0003_add_config_options.py b/common/djangoapps/third_party_auth/migrations/0003_add_config_options.py new file mode 100644 index 0000000000..6ff8a3d3a5 --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0003_add_config_options.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'SAMLProviderConfig.secondary' + db.add_column('third_party_auth_samlproviderconfig', 'secondary', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'SAMLProviderConfig.skip_registration_form' + db.add_column('third_party_auth_samlproviderconfig', 'skip_registration_form', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'SAMLProviderConfig.skip_email_verification' + db.add_column('third_party_auth_samlproviderconfig', 'skip_email_verification', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'OAuth2ProviderConfig.secondary' + db.add_column('third_party_auth_oauth2providerconfig', 'secondary', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'OAuth2ProviderConfig.skip_registration_form' + db.add_column('third_party_auth_oauth2providerconfig', 'skip_registration_form', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'OAuth2ProviderConfig.skip_email_verification' + db.add_column('third_party_auth_oauth2providerconfig', 'skip_email_verification', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'SAMLProviderConfig.secondary' + db.delete_column('third_party_auth_samlproviderconfig', 'secondary') + + # Deleting field 'SAMLProviderConfig.skip_registration_form' + db.delete_column('third_party_auth_samlproviderconfig', 'skip_registration_form') + + # Deleting field 'SAMLProviderConfig.skip_email_verification' + db.delete_column('third_party_auth_samlproviderconfig', 'skip_email_verification') + + # Deleting field 'OAuth2ProviderConfig.secondary' + db.delete_column('third_party_auth_oauth2providerconfig', 'secondary') + + # Deleting field 'OAuth2ProviderConfig.skip_registration_form' + db.delete_column('third_party_auth_oauth2providerconfig', 'skip_registration_form') + + # Deleting field 'OAuth2ProviderConfig.skip_email_verification' + db.delete_column('third_party_auth_oauth2providerconfig', 'skip_email_verification') + + + 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'}) + }, + 'third_party_auth.oauth2providerconfig': { + 'Meta': {'object_name': 'OAuth2ProviderConfig'}, + 'backend_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'secondary': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'secret': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'skip_email_verification': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'skip_registration_form': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'third_party_auth.samlconfiguration': { + 'Meta': {'object_name': 'SAMLConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'entity_id': ('django.db.models.fields.CharField', [], {'default': "'http://saml.example.com'", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'org_info_str': ('django.db.models.fields.TextField', [], {'default': '\'{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}\''}), + 'other_config_str': ('django.db.models.fields.TextField', [], {'default': '\'{\\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\\n}\''}), + 'private_key': ('django.db.models.fields.TextField', [], {}), + 'public_key': ('django.db.models.fields.TextField', [], {}) + }, + 'third_party_auth.samlproviderconfig': { + 'Meta': {'object_name': 'SAMLProviderConfig'}, + 'attr_email': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_first_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_full_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_last_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_user_permanent_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_username': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'backend_name': ('django.db.models.fields.CharField', [], {'default': "'tpa-saml'", 'max_length': '50'}), + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'idp_slug': ('django.db.models.fields.SlugField', [], {'max_length': '30'}), + 'metadata_source': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'secondary': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'skip_email_verification': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'skip_registration_form': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'third_party_auth.samlproviderdata': { + 'Meta': {'ordering': "('-fetched_at',)", 'object_name': 'SAMLProviderData'}, + 'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'expires_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'fetched_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'public_key': ('django.db.models.fields.TextField', [], {}), + 'sso_url': ('django.db.models.fields.URLField', [], {'max_length': '200'}) + } + } + + complete_apps = ['third_party_auth'] \ No newline at end of file diff --git a/common/djangoapps/third_party_auth/migrations/__init__.py b/common/djangoapps/third_party_auth/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py new file mode 100644 index 0000000000..1550c54eba --- /dev/null +++ b/common/djangoapps/third_party_auth/models.py @@ -0,0 +1,420 @@ +# -*- coding: utf-8 -*- +""" +Models used to implement SAML SSO support in third_party_auth +(inlcuding Shibboleth support) +""" +from config_models.models import ConfigurationModel, cache +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ +import json +import logging +from social.backends.base import BaseAuth +from social.backends.oauth import BaseOAuth2 +from social.backends.saml import SAMLAuth, SAMLIdentityProvider +from social.exceptions import SocialAuthBaseException +from social.utils import module_member + +log = logging.getLogger(__name__) + + +# A dictionary of {name: class} entries for each python-social-auth backend available. +# Because this setting can specify arbitrary code to load and execute, it is set via +# normal Django settings only and cannot be changed at runtime: +def _load_backend_classes(base_class=BaseAuth): + """ Load the list of python-social-auth backend classes from Django settings """ + for class_path in settings.AUTHENTICATION_BACKENDS: + auth_class = module_member(class_path) + if issubclass(auth_class, base_class): + yield auth_class +_PSA_BACKENDS = {backend_class.name: backend_class for backend_class in _load_backend_classes()} +_PSA_OAUTH2_BACKENDS = [backend_class.name for backend_class in _load_backend_classes(BaseOAuth2)] +_PSA_SAML_BACKENDS = [backend_class.name for backend_class in _load_backend_classes(SAMLAuth)] + + +def clean_json(value, of_type): + """ Simple helper method to parse and clean JSON """ + if not value.strip(): + return json.dumps(of_type()) + try: + value_python = json.loads(value) + except ValueError as err: + raise ValidationError("Invalid JSON: {}".format(err.message)) + if not isinstance(value_python, of_type): + raise ValidationError("Expected a JSON {}".format(of_type)) + return json.dumps(value_python, indent=4) + + +class AuthNotConfigured(SocialAuthBaseException): + """ Exception when SAMLProviderData or other required info is missing """ + def __init__(self, provider_name): + super(AuthNotConfigured, self).__init__() + self.provider_name = provider_name + + def __str__(self): + return _('Authentication with {} is currently unavailable.').format( # pylint: disable=no-member + self.provider_name + ) + + +class ProviderConfig(ConfigurationModel): + """ + Abstract Base Class for configuring a third_party_auth provider + """ + icon_class = models.CharField( + max_length=50, default='fa-sign-in', + help_text=( + 'The Font Awesome (or custom) icon class to use on the login button for this provider. ' + 'Examples: fa-google-plus, fa-facebook, fa-linkedin, fa-sign-in, fa-university' + ), + ) + name = models.CharField(max_length=50, blank=False, help_text="Name of this provider (shown to users)") + secondary = models.BooleanField( + default=False, + help_text=_( + 'Secondary providers are displayed less prominently, ' + 'in a separate list of "Institution" login providers.' + ), + ) + skip_registration_form = models.BooleanField( + default=False, + help_text=_( + "If this option is enabled, users will not be asked to confirm their details " + "(name, email, etc.) during the registration process. Only select this option " + "for trusted providers that are known to provide accurate user information." + ), + ) + skip_email_verification = models.BooleanField( + default=False, + help_text=_( + "If this option is selected, users will not be required to confirm their " + "email, and their account will be activated immediately upon registration." + ), + ) + prefix = None # used for provider_id. Set to a string value in subclass + backend_name = None # Set to a field or fixed value in subclass + + # "enabled" field is inherited from ConfigurationModel + + class Meta(object): # pylint: disable=missing-docstring + abstract = True + + @property + def provider_id(self): + """ Unique string key identifying this provider. Must be URL and css class friendly. """ + assert self.prefix is not None + return "-".join((self.prefix, ) + tuple(getattr(self, field) for field in self.KEY_FIELDS)) + + @property + def backend_class(self): + """ Get the python-social-auth backend class used for this provider """ + return _PSA_BACKENDS[self.backend_name] + + def get_url_params(self): + """ Get a dict of GET parameters to append to login links for this provider """ + return {} + + def is_active_for_pipeline(self, pipeline): + """ Is this provider being used for the specified pipeline? """ + return self.backend_name == pipeline['backend'] + + def match_social_auth(self, social_auth): + """ Is this provider being used for this UserSocialAuth entry? """ + return self.backend_name == social_auth.provider + + @classmethod + def get_register_form_data(cls, pipeline_kwargs): + """Gets dict of data to display on the register form. + + common.djangoapps.student.views.register_user uses this to populate the + new account creation form with values supplied by the user's chosen + provider, preventing duplicate data entry. + + Args: + pipeline_kwargs: dict of string -> object. Keyword arguments + accumulated by the pipeline thus far. + + Returns: + Dict of string -> string. Keys are names of form fields; values are + values for that field. Where there is no value, the empty string + must be used. + """ + # Details about the user sent back from the provider. + details = pipeline_kwargs.get('details') + + # Get the username separately to take advantage of the de-duping logic + # built into the pipeline. The provider cannot de-dupe because it can't + # check the state of taken usernames in our system. Note that there is + # technically a data race between the creation of this value and the + # creation of the user object, so it is still possible for users to get + # an error on submit. + suggested_username = pipeline_kwargs.get('username') + + return { + 'email': details.get('email', ''), + 'name': details.get('fullname', ''), + 'username': suggested_username, + } + + def get_authentication_backend(self): + """Gets associated Django settings.AUTHENTICATION_BACKEND string.""" + return '{}.{}'.format(self.backend_class.__module__, self.backend_class.__name__) + + +class OAuth2ProviderConfig(ProviderConfig): + """ + Configuration Entry for an OAuth2 based provider. + """ + prefix = 'oa2' + KEY_FIELDS = ('backend_name', ) # Backend name is unique + backend_name = models.CharField( + max_length=50, choices=[(name, name) for name in _PSA_OAUTH2_BACKENDS], blank=False, db_index=True, + help_text=( + "Which python-social-auth OAuth2 provider backend to use. " + "The list of backend choices is determined by the THIRD_PARTY_AUTH_BACKENDS setting." + # To be precise, it's set by AUTHENTICATION_BACKENDS - which aws.py sets from THIRD_PARTY_AUTH_BACKENDS + ) + ) + key = models.TextField(blank=True, verbose_name="Client ID") + secret = models.TextField(blank=True, verbose_name="Client Secret") + other_settings = models.TextField(blank=True, help_text="Optional JSON object with advanced settings, if any.") + + class Meta(object): # pylint: disable=missing-docstring + verbose_name = "Provider Configuration (OAuth2)" + verbose_name_plural = verbose_name + + def clean(self): + """ Standardize and validate fields """ + super(OAuth2ProviderConfig, self).clean() + self.other_settings = clean_json(self.other_settings, dict) + + def get_setting(self, name): + """ Get the value of a setting, or raise KeyError """ + if name in ("KEY", "SECRET"): + return getattr(self, name.lower()) + if self.other_settings: + other_settings = json.loads(self.other_settings) + assert isinstance(other_settings, dict), "other_settings should be a JSON object (dictionary)" + return other_settings[name] + raise KeyError + + +class SAMLProviderConfig(ProviderConfig): + """ + Configuration Entry for a SAML/Shibboleth provider. + """ + prefix = 'saml' + KEY_FIELDS = ('idp_slug', ) + backend_name = models.CharField( + max_length=50, default='tpa-saml', choices=[(name, name) for name in _PSA_SAML_BACKENDS], blank=False, + help_text="Which python-social-auth provider backend to use. 'tpa-saml' is the standard edX SAML backend.") + idp_slug = models.SlugField( + max_length=30, db_index=True, + help_text=( + 'A short string uniquely identifying this provider. ' + 'Cannot contain spaces and should be a usable as a CSS class. Examples: "ubc", "mit-staging"' + )) + entity_id = models.CharField( + max_length=255, verbose_name="Entity ID", help_text="Example: https://idp.testshib.org/idp/shibboleth") + metadata_source = models.CharField( + max_length=255, + help_text=( + "URL to this provider's XML metadata. Should be an HTTPS URL. " + "Example: https://www.testshib.org/metadata/testshib-providers.xml" + )) + attr_user_permanent_id = models.CharField( + max_length=128, blank=True, verbose_name="User ID Attribute", + help_text="URN of the SAML attribute that we can use as a unique, persistent user ID. Leave blank for default.") + attr_full_name = models.CharField( + max_length=128, blank=True, verbose_name="Full Name Attribute", + help_text="URN of SAML attribute containing the user's full name. Leave blank for default.") + attr_first_name = models.CharField( + max_length=128, blank=True, verbose_name="First Name Attribute", + help_text="URN of SAML attribute containing the user's first name. Leave blank for default.") + attr_last_name = models.CharField( + max_length=128, blank=True, verbose_name="Last Name Attribute", + help_text="URN of SAML attribute containing the user's last name. Leave blank for default.") + attr_username = models.CharField( + max_length=128, blank=True, verbose_name="Username Hint Attribute", + help_text="URN of SAML attribute to use as a suggested username for this user. Leave blank for default.") + attr_email = models.CharField( + max_length=128, blank=True, verbose_name="Email Attribute", + help_text="URN of SAML attribute containing the user's email address[es]. Leave blank for default.") + other_settings = models.TextField( + verbose_name="Advanced settings", blank=True, + help_text=( + 'For advanced use cases, enter a JSON object with addtional configuration. ' + 'The tpa-saml backend supports only {"requiredEntitlements": ["urn:..."]} ' + 'which can be used to require the presence of a specific eduPersonEntitlement.' + )) + + def clean(self): + """ Standardize and validate fields """ + super(SAMLProviderConfig, self).clean() + self.other_settings = clean_json(self.other_settings, dict) + + class Meta(object): # pylint: disable=missing-docstring + verbose_name = "Provider Configuration (SAML IdP)" + verbose_name_plural = "Provider Configuration (SAML IdPs)" + + def get_url_params(self): + """ Get a dict of GET parameters to append to login links for this provider """ + return {'idp': self.idp_slug} + + def is_active_for_pipeline(self, pipeline): + """ Is this provider being used for the specified pipeline? """ + return self.backend_name == pipeline['backend'] and self.idp_slug == pipeline['kwargs']['response']['idp_name'] + + def match_social_auth(self, social_auth): + """ Is this provider being used for this UserSocialAuth entry? """ + prefix = self.idp_slug + ":" + return self.backend_name == social_auth.provider and social_auth.uid.startswith(prefix) + + def get_config(self): + """ + Return a SAMLIdentityProvider instance for use by SAMLAuthBackend. + + Essentially this just returns the values of this object and its + associated 'SAMLProviderData' entry. + """ + if self.other_settings: + conf = json.loads(self.other_settings) + else: + conf = {} + attrs = ( + 'attr_user_permanent_id', 'attr_full_name', 'attr_first_name', + 'attr_last_name', 'attr_username', 'attr_email', 'entity_id') + for field in attrs: + val = getattr(self, field) + if val: + conf[field] = val + # Now get the data fetched automatically from the metadata.xml: + data = SAMLProviderData.current(self.entity_id) + if not data or not data.is_valid(): + log.error("No SAMLProviderData found for %s. Run 'manage.py saml pull' to fix or debug.", self.entity_id) + raise AuthNotConfigured(provider_name=self.name) + conf['x509cert'] = data.public_key + conf['url'] = data.sso_url + return SAMLIdentityProvider(self.idp_slug, **conf) + + +class SAMLConfiguration(ConfigurationModel): + """ + General configuration required for this edX instance to act as a SAML + Service Provider and allow users to authenticate via third party SAML + Identity Providers (IdPs) + """ + private_key = models.TextField( + help_text=( + 'To generate a key pair as two files, run ' + '"openssl req -new -x509 -days 3652 -nodes -out saml.crt -keyout saml.key". ' + 'Paste the contents of saml.key here.' + ) + ) + public_key = models.TextField(help_text="Public key certificate.") + entity_id = models.CharField(max_length=255, default="http://saml.example.com", verbose_name="Entity ID") + org_info_str = models.TextField( + verbose_name="Organization Info", + default='{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}', + help_text="JSON dictionary of 'url', 'displayname', and 'name' for each language", + ) + other_config_str = models.TextField( + default='{\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\n}', + help_text=( + "JSON object defining advanced settings that are passed on to python-saml. " + "Valid keys that can be set here include: SECURITY_CONFIG and SP_EXTRA" + ), + ) + + class Meta(object): # pylint: disable=missing-docstring + verbose_name = "SAML Configuration" + verbose_name_plural = verbose_name + + def clean(self): + """ Standardize and validate fields """ + super(SAMLConfiguration, self).clean() + self.org_info_str = clean_json(self.org_info_str, dict) + self.other_config_str = clean_json(self.other_config_str, dict) + + self.private_key = ( + self.private_key + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .strip() + ) + self.public_key = ( + self.public_key + .replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .strip() + ) + + def get_setting(self, name): + """ Get the value of a setting, or raise KeyError """ + if name == "ORG_INFO": + return json.loads(self.org_info_str) + if name == "SP_ENTITY_ID": + return self.entity_id + if name == "SP_PUBLIC_CERT": + return self.public_key + if name == "SP_PRIVATE_KEY": + return self.private_key + if name == "TECHNICAL_CONTACT": + return {"givenName": "Technical Support", "emailAddress": settings.TECH_SUPPORT_EMAIL} + if name == "SUPPORT_CONTACT": + return {"givenName": "SAML Support", "emailAddress": settings.TECH_SUPPORT_EMAIL} + other_config = json.loads(self.other_config_str) + return other_config[name] # SECURITY_CONFIG, SP_EXTRA, or similar extra settings + + +class SAMLProviderData(models.Model): + """ + Data about a SAML IdP that is fetched automatically by 'manage.py saml pull' + + This data is only required during the actual authentication process. + """ + cache_timeout = 600 + fetched_at = models.DateTimeField(db_index=True, null=False) + expires_at = models.DateTimeField(db_index=True, null=True) + + entity_id = models.CharField(max_length=255, db_index=True) # This is the key for lookups in this table + sso_url = models.URLField(verbose_name="SSO URL") + public_key = models.TextField() + + class Meta(object): # pylint: disable=missing-docstring + verbose_name = "SAML Provider Data" + verbose_name_plural = verbose_name + ordering = ('-fetched_at', ) + + def is_valid(self): + """ Is this data valid? """ + if self.expires_at and timezone.now() > self.expires_at: + return False + return bool(self.entity_id and self.sso_url and self.public_key) + is_valid.boolean = True + + @classmethod + def cache_key_name(cls, entity_id): + """ Return the name of the key to use to cache the current data """ + return 'configuration/{}/current/{}'.format(cls.__name__, entity_id) + + @classmethod + def current(cls, entity_id): + """ + Return the active data entry, if any, otherwise None + """ + cached = cache.get(cls.cache_key_name(entity_id)) + if cached is not None: + return cached + + try: + current = cls.objects.filter(entity_id=entity_id).order_by('-fetched_at')[0] + except IndexError: + current = None + + cache.set(cls.cache_key_name(entity_id), current, cls.cache_timeout) + return current diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index 7c0ad27c08..5bc4f069dd 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -196,9 +196,11 @@ class ProviderUserState(object): lms/templates/dashboard.html. """ - def __init__(self, enabled_provider, user, state): + def __init__(self, enabled_provider, user, association_id=None): + # UserSocialAuth row ID + self.association_id = association_id # Boolean. Whether the user has an account associated with the provider - self.has_account = state + self.has_account = association_id is not None # provider.BaseProvider child. Callers must verify that the provider is # enabled. self.provider = enabled_provider @@ -207,7 +209,7 @@ class ProviderUserState(object): def get_unlink_form_name(self): """Gets the name used in HTML forms that unlink a provider account.""" - return self.provider.NAME + '_unlink_form' + return self.provider.provider_id + '_unlink_form' def get(request): @@ -215,7 +217,7 @@ def get(request): return request.session.get('partial_pipeline') -def get_authenticated_user(username, backend_name): +def get_authenticated_user(auth_provider, username, uid): """Gets a saved user authenticated by a particular backend. Between pipeline steps User objects are not saved. We need to reconstitute @@ -224,43 +226,45 @@ def get_authenticated_user(username, backend_name): authenticate(). Args: + auth_provider: the third_party_auth provider in use for the current pipeline. username: string. Username of user to get. - backend_name: string. The name of the third-party auth backend from - the running pipeline. + uid: string. The user ID according to the third party. Returns: User if user is found and has a social auth from the passed - backend_name. + provider. Raises: User.DoesNotExist: if no user matching user is found, or the matching user has no social auth associated with the given backend. AssertionError: if the user is not authenticated. """ - user = models.DjangoStorage.user.user_model().objects.get(username=username) - match = models.DjangoStorage.user.get_social_auth_for_user(user, provider=backend_name) + match = models.DjangoStorage.user.get_social_auth(provider=auth_provider.backend_name, uid=uid) - if not match: + if not match or match.user.username != username: raise User.DoesNotExist - user.backend = provider.Registry.get_by_backend_name(backend_name).get_authentication_backend() + user = match.user + user.backend = auth_provider.get_authentication_backend() return user -def _get_enabled_provider_by_name(provider_name): - """Gets an enabled provider by its NAME member or throws.""" - enabled_provider = provider.Registry.get(provider_name) +def _get_enabled_provider(provider_id): + """Gets an enabled provider by its provider_id member or throws.""" + enabled_provider = provider.Registry.get(provider_id) if not enabled_provider: - raise ValueError('Provider %s not enabled' % provider_name) + raise ValueError('Provider %s not enabled' % provider_id) return enabled_provider -def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None): +def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None, + extra_params=None, url_params=None): """Creates a URL to hook into social auth endpoints.""" - kwargs = {'backend': backend_name} - url = reverse(view_name, kwargs=kwargs) + url_params = url_params or {} + url_params['backend'] = backend_name + url = reverse(view_name, kwargs=url_params) query_params = OrderedDict() if auth_entry: @@ -269,6 +273,9 @@ def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None): if redirect_url: query_params[AUTH_REDIRECT_KEY] = redirect_url + if extra_params: + query_params.update(extra_params) + return u"{url}?{params}".format( url=url, params=urllib.urlencode(query_params) @@ -288,37 +295,40 @@ def get_complete_url(backend_name): Raises: ValueError: if no provider is enabled with the given backend_name. """ - enabled_provider = provider.Registry.get_by_backend_name(backend_name) - - if not enabled_provider: + if not any(provider.Registry.get_enabled_by_backend_name(backend_name)): raise ValueError('Provider with backend %s not enabled' % backend_name) return _get_url('social:complete', backend_name) -def get_disconnect_url(provider_name): +def get_disconnect_url(provider_id, association_id): """Gets URL for the endpoint that starts the disconnect pipeline. Args: - provider_name: string. Name of the provider.BaseProvider child you want + provider_id: string identifier of the models.ProviderConfig child you want to disconnect from. + association_id: int. Optional ID of a specific row in the UserSocialAuth + table to disconnect (useful if multiple providers use a common backend) Returns: String. URL that starts the disconnection pipeline. Raises: - ValueError: if no provider is enabled with the given backend_name. + ValueError: if no provider is enabled with the given ID. """ - enabled_provider = _get_enabled_provider_by_name(provider_name) - return _get_url('social:disconnect', enabled_provider.BACKEND_CLASS.name) + backend_name = _get_enabled_provider(provider_id).backend_name + if association_id: + return _get_url('social:disconnect_individual', backend_name, url_params={'association_id': association_id}) + else: + return _get_url('social:disconnect', backend_name) -def get_login_url(provider_name, auth_entry, redirect_url=None): +def get_login_url(provider_id, auth_entry, redirect_url=None): """Gets the login URL for the endpoint that kicks off auth with a provider. Args: - provider_name: string. The name of the provider.Provider that has been - enabled. + provider_id: string identifier of the models.ProviderConfig child you want + to disconnect from. auth_entry: string. Query argument specifying the desired entry point for the auth pipeline. Used by the pipeline for later branching. Must be one of _AUTH_ENTRY_CHOICES. @@ -331,15 +341,16 @@ def get_login_url(provider_name, auth_entry, redirect_url=None): String. URL that starts the auth pipeline for a provider. Raises: - ValueError: if no provider is enabled with the given provider_name. + ValueError: if no provider is enabled with the given provider_id. """ assert auth_entry in _AUTH_ENTRY_CHOICES - enabled_provider = _get_enabled_provider_by_name(provider_name) + enabled_provider = _get_enabled_provider(provider_id) return _get_url( 'social:begin', - enabled_provider.BACKEND_CLASS.name, + enabled_provider.backend_name, auth_entry=auth_entry, redirect_url=redirect_url, + extra_params=enabled_provider.get_url_params(), ) @@ -355,7 +366,7 @@ def get_duplicate_provider(messages): unfortunately not in a reusable constant. Returns: - provider.BaseProvider child instance. The provider of the duplicate + string name of the python-social-auth backend that has the duplicate account, or None if there is no duplicate (and hence no error). """ social_auth_messages = [m for m in messages if m.message.endswith('is already in use.')] @@ -364,7 +375,8 @@ def get_duplicate_provider(messages): return assert len(social_auth_messages) == 1 - return provider.Registry.get_by_backend_name(social_auth_messages[0].extra_tags.split()[1]) + backend_name = social_auth_messages[0].extra_tags.split()[1] + return backend_name def get_provider_user_states(user): @@ -378,13 +390,16 @@ def get_provider_user_states(user): each enabled provider. """ states = [] - found_user_backends = [ - social_auth.provider for social_auth in models.DjangoStorage.user.get_social_auth_for_user(user) - ] + found_user_auths = list(models.DjangoStorage.user.get_social_auth_for_user(user)) for enabled_provider in provider.Registry.enabled(): + association_id = None + for auth in found_user_auths: + if enabled_provider.match_social_auth(auth): + association_id = auth.id + break states.append( - ProviderUserState(enabled_provider, user, enabled_provider.BACKEND_CLASS.name in found_user_backends) + ProviderUserState(enabled_provider, user, association_id) ) return states @@ -488,12 +503,19 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia """Redirects to the registration page.""" return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_REGISTER]) + def should_force_account_creation(): + """ For some third party providers, we auto-create user accounts """ + current_provider = provider.Registry.get_from_pipeline({'backend': backend.name, 'kwargs': kwargs}) + return current_provider and current_provider.skip_email_verification + if not user: if auth_entry in [AUTH_ENTRY_LOGIN_API, AUTH_ENTRY_REGISTER_API]: return HttpResponseBadRequest() elif auth_entry in [AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN_2]: # User has authenticated with the third party provider but we don't know which edX # account corresponds to them yet, if any. + if should_force_account_creation(): + return dispatch_to_register() return dispatch_to_login() elif auth_entry in [AUTH_ENTRY_REGISTER, AUTH_ENTRY_REGISTER_2]: # User has authenticated with the third party provider and now wants to finish diff --git a/common/djangoapps/third_party_auth/provider.py b/common/djangoapps/third_party_auth/provider.py index 9f0809d42a..415e670900 100644 --- a/common/djangoapps/third_party_auth/provider.py +++ b/common/djangoapps/third_party_auth/provider.py @@ -1,234 +1,85 @@ -"""Third-party auth provider definitions. - -Loaded by Django's settings mechanism. Consequently, this module must not -invoke the Django armature. """ - -from social.backends import google, linkedin, facebook - -_DEFAULT_ICON_CLASS = 'fa-signin' - - -class BaseProvider(object): - """Abstract base class for third-party auth providers. - - All providers must subclass BaseProvider -- otherwise, they cannot be put - in the provider Registry. - """ - - # Class. The provider's backing social.backends.base.BaseAuth child. - BACKEND_CLASS = None - # String. Name of the FontAwesome glyph to use for sign in buttons (or the - # name of a user-supplied custom glyph that is present at runtime). - ICON_CLASS = _DEFAULT_ICON_CLASS - # String. User-facing name of the provider. Must be unique across all - # enabled providers. Will be presented in the UI. - NAME = None - # Dict of string -> object. Settings that will be merged into Django's - # settings instance. In most cases the value will be None, since real - # values are merged from .json files (foo.auth.json; foo.env.json) onto the - # settings instance during application initialization. - SETTINGS = {} - - @classmethod - def get_authentication_backend(cls): - """Gets associated Django settings.AUTHENTICATION_BACKEND string.""" - return '%s.%s' % (cls.BACKEND_CLASS.__module__, cls.BACKEND_CLASS.__name__) - - @classmethod - def get_email(cls, provider_details): - """Gets user's email address. - - Provider responses can contain arbitrary data. This method can be - overridden to extract an email address from the provider details - extracted by the social_details pipeline step. - - Args: - provider_details: dict of string -> string. Data about the - user passed back by the provider. - - Returns: - String or None. The user's email address, if any. - """ - return provider_details.get('email') - - @classmethod - def get_name(cls, provider_details): - """Gets user's name. - - Provider responses can contain arbitrary data. This method can be - overridden to extract a full name for a user from the provider details - extracted by the social_details pipeline step. - - Args: - provider_details: dict of string -> string. Data about the - user passed back by the provider. - - Returns: - String or None. The user's full name, if any. - """ - return provider_details.get('fullname') - - @classmethod - def get_register_form_data(cls, pipeline_kwargs): - """Gets dict of data to display on the register form. - - common.djangoapps.student.views.register_user uses this to populate the - new account creation form with values supplied by the user's chosen - provider, preventing duplicate data entry. - - Args: - pipeline_kwargs: dict of string -> object. Keyword arguments - accumulated by the pipeline thus far. - - Returns: - Dict of string -> string. Keys are names of form fields; values are - values for that field. Where there is no value, the empty string - must be used. - """ - # Details about the user sent back from the provider. - details = pipeline_kwargs.get('details') - - # Get the username separately to take advantage of the de-duping logic - # built into the pipeline. The provider cannot de-dupe because it can't - # check the state of taken usernames in our system. Note that there is - # technically a data race between the creation of this value and the - # creation of the user object, so it is still possible for users to get - # an error on submit. - suggested_username = pipeline_kwargs.get('username') - - return { - 'email': cls.get_email(details) or '', - 'name': cls.get_name(details) or '', - 'username': suggested_username, - } - - @classmethod - def merge_onto(cls, settings): - """Merge class-level settings onto a django settings module.""" - for key, value in cls.SETTINGS.iteritems(): - setattr(settings, key, value) - - -class GoogleOauth2(BaseProvider): - """Provider for Google's Oauth2 auth system.""" - - BACKEND_CLASS = google.GoogleOAuth2 - ICON_CLASS = 'fa-google-plus' - NAME = 'Google' - SETTINGS = { - 'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': None, - 'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET': None, - } - - -class LinkedInOauth2(BaseProvider): - """Provider for LinkedIn's Oauth2 auth system.""" - - BACKEND_CLASS = linkedin.LinkedinOAuth2 - ICON_CLASS = 'fa-linkedin' - NAME = 'LinkedIn' - SETTINGS = { - 'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': None, - 'SOCIAL_AUTH_LINKEDIN_OAUTH2_SECRET': None, - } - - -class FacebookOauth2(BaseProvider): - """Provider for LinkedIn's Oauth2 auth system.""" - - BACKEND_CLASS = facebook.FacebookOAuth2 - ICON_CLASS = 'fa-facebook' - NAME = 'Facebook' - SETTINGS = { - 'SOCIAL_AUTH_FACEBOOK_KEY': None, - 'SOCIAL_AUTH_FACEBOOK_SECRET': None, - } +Third-party auth provider configuration API. +""" +from .models import ( + OAuth2ProviderConfig, SAMLConfiguration, SAMLProviderConfig, + _PSA_OAUTH2_BACKENDS, _PSA_SAML_BACKENDS +) class Registry(object): - """Singleton registry of third-party auth providers. - - Providers must subclass BaseProvider in order to be usable in the registry. """ + API for querying third-party auth ProviderConfig objects. - _CONFIGURED = False - _ENABLED = {} - + Providers must subclass ProviderConfig in order to be usable in the registry. + """ @classmethod - def _check_configured(cls): - """Ensures registry is configured.""" - if not cls._CONFIGURED: - raise RuntimeError('Registry not configured') - - @classmethod - def _get_all(cls): - """Gets all provider implementations loaded into the Python runtime.""" - # BaseProvider does so have __subclassess__. pylint: disable-msg=no-member - return {klass.NAME: klass for klass in BaseProvider.__subclasses__()} - - @classmethod - def _enable(cls, provider): - """Enables a single provider.""" - if provider.NAME in cls._ENABLED: - raise ValueError('Provider %s already enabled' % provider.NAME) - cls._ENABLED[provider.NAME] = provider - - @classmethod - def configure_once(cls, provider_names): - """Configures providers. - - Args: - provider_names: list of string. The providers to configure. - - Raises: - ValueError: if the registry has already been configured, or if any - of the passed provider_names does not have a corresponding - BaseProvider child implementation. - """ - if cls._CONFIGURED: - raise ValueError('Provider registry already configured') - - # Flip the bit eagerly -- configure() should not be re-callable if one - # _enable call fails. - cls._CONFIGURED = True - for name in provider_names: - all_providers = cls._get_all() - if name not in all_providers: - raise ValueError('No implementation found for provider ' + name) - cls._enable(all_providers.get(name)) + def _enabled_providers(cls): + """ Helper method to iterate over all providers """ + for backend_name in _PSA_OAUTH2_BACKENDS: + provider = OAuth2ProviderConfig.current(backend_name) + if provider.enabled: + yield provider + if SAMLConfiguration.is_enabled(): + idp_slugs = SAMLProviderConfig.key_values('idp_slug', flat=True) + for idp_slug in idp_slugs: + provider = SAMLProviderConfig.current(idp_slug) + if provider.enabled and provider.backend_name in _PSA_SAML_BACKENDS: + yield provider @classmethod def enabled(cls): """Returns list of enabled providers.""" - cls._check_configured() - return sorted(cls._ENABLED.values(), key=lambda provider: provider.NAME) + return sorted(cls._enabled_providers(), key=lambda provider: provider.name) @classmethod - def get(cls, provider_name): - """Gets provider named provider_name string if enabled, else None.""" - cls._check_configured() - return cls._ENABLED.get(provider_name) + def get(cls, provider_id): + """Gets provider by provider_id string if enabled, else None.""" + if '-' not in provider_id: # Check format - see models.py:ProviderConfig + raise ValueError("Invalid provider_id. Expect something like oa2-google") + try: + return next(provider for provider in cls._enabled_providers() if provider.provider_id == provider_id) + except StopIteration: + return None @classmethod - def get_by_backend_name(cls, backend_name): - """Gets provider (or None) by backend name. + def get_from_pipeline(cls, running_pipeline): + """Gets the provider that is being used for the specified pipeline (or None). Args: - backend_name: string. The python-social-auth - backends.base.BaseAuth.name (for example, 'google-oauth2') to - try and get a provider for. + running_pipeline: The python-social-auth pipeline being used to + authenticate a user. - Raises: - RuntimeError: if the registry has not been configured. + Returns: + An instance of ProviderConfig or None. """ - cls._check_configured() - for enabled in cls._ENABLED.values(): - if enabled.BACKEND_CLASS.name == backend_name: + for enabled in cls._enabled_providers(): + if enabled.is_active_for_pipeline(running_pipeline): return enabled @classmethod - def _reset(cls): - """Returns the registry to an unconfigured state; for tests only.""" - cls._CONFIGURED = False - cls._ENABLED = {} + def get_enabled_by_backend_name(cls, backend_name): + """Generator returning all enabled providers that use the specified + backend. + + Example: + >>> list(get_enabled_by_backend_name("tpa-saml")) + [, ] + + Args: + backend_name: The name of a python-social-auth backend used by + one or more providers. + + Yields: + Instances of ProviderConfig. + """ + if backend_name in _PSA_OAUTH2_BACKENDS: + provider = OAuth2ProviderConfig.current(backend_name) + if provider.enabled: + yield provider + elif backend_name in _PSA_SAML_BACKENDS and SAMLConfiguration.is_enabled(): + idp_names = SAMLProviderConfig.key_values('idp_slug', flat=True) + for idp_name in idp_names: + provider = SAMLProviderConfig.current(idp_name) + if provider.backend_name == backend_name and provider.enabled: + yield provider diff --git a/common/djangoapps/third_party_auth/saml.py b/common/djangoapps/third_party_auth/saml.py new file mode 100644 index 0000000000..db40104b14 --- /dev/null +++ b/common/djangoapps/third_party_auth/saml.py @@ -0,0 +1,49 @@ +""" +Slightly customized python-social-auth backend for SAML 2.0 support +""" +import logging +from social.backends.saml import SAMLAuth, OID_EDU_PERSON_ENTITLEMENT +from social.exceptions import AuthForbidden + +log = logging.getLogger(__name__) + + +class SAMLAuthBackend(SAMLAuth): # pylint: disable=abstract-method + """ + Customized version of SAMLAuth that gets the list of IdPs from third_party_auth's list of + enabled providers. + """ + name = "tpa-saml" + + def get_idp(self, idp_name): + """ Given the name of an IdP, get a SAMLIdentityProvider instance """ + from .models import SAMLProviderConfig + return SAMLProviderConfig.current(idp_name).get_config() + + def setting(self, name, default=None): + """ Get a setting, from SAMLConfiguration """ + if not hasattr(self, '_config'): + from .models import SAMLConfiguration + self._config = SAMLConfiguration.current() # pylint: disable=attribute-defined-outside-init + if not self._config.enabled: + from django.core.exceptions import ImproperlyConfigured + raise ImproperlyConfigured("SAML Authentication is not enabled.") + try: + return self._config.get_setting(name) + except KeyError: + return self.strategy.setting(name, default) + + def _check_entitlements(self, idp, attributes): + """ + Check if we require the presence of any specific eduPersonEntitlement. + + raise AuthForbidden if the user should not be authenticated, or do nothing + to allow the login pipeline to continue. + """ + if "requiredEntitlements" in idp.conf: + entitlements = attributes.get(OID_EDU_PERSON_ENTITLEMENT, []) + for expected in idp.conf['requiredEntitlements']: + if expected not in entitlements: + log.warning( + "SAML user from IdP %s rejected due to missing eduPersonEntitlement %s", idp.name, expected) + raise AuthForbidden(self) diff --git a/common/djangoapps/third_party_auth/settings.py b/common/djangoapps/third_party_auth/settings.py index e9468b7ce8..a856aefa4f 100644 --- a/common/djangoapps/third_party_auth/settings.py +++ b/common/djangoapps/third_party_auth/settings.py @@ -1,51 +1,15 @@ """Settings for the third-party auth module. -Defers configuration of settings so we can inspect the provider registry and -create settings placeholders for only those values actually needed by a given -deployment. Required by Django; consequently, this file must not invoke the -Django armature. - The flow for settings registration is: The base settings file contains a boolean, ENABLE_THIRD_PARTY_AUTH, indicating -whether this module is enabled. Ancillary settings files (aws.py, dev.py) put -options in THIRD_PARTY_SETTINGS. startup.py probes the ENABLE_THIRD_PARTY_AUTH. +whether this module is enabled. startup.py probes the ENABLE_THIRD_PARTY_AUTH. If true, it: a) loads this module. - b) calls apply_settings(), passing in settings.THIRD_PARTY_AUTH. - THIRD_PARTY AUTH is a dict of the form - - 'THIRD_PARTY_AUTH': { - '': { - '': '', - [...] - }, - [...] - } - - If you are using a dev settings file, your settings dict starts at the - level of and is a map of provider name string to - settings dict. If you are using an auth.json file, it should contain a - THIRD_PARTY_AUTH entry as above. - c) apply_settings() builds a list of . These are the - enabled third party auth providers for the deployment. These are enabled - in provider.Registry, the canonical list of enabled providers. - d) then, it sets global, provider-independent settings. - e) then, it sets provider-specific settings. For each enabled provider, we - read its SETTINGS member. These are merged onto the Django settings - object. In most cases these are stubs and the real values are set from - THIRD_PARTY_AUTH. All values that are set from this dict must first be - initialized from SETTINGS. This allows us to validate the dict and - ensure that the values match expected configuration options on the - provider. - f) finally, the (key, value) pairs from the dict file are merged onto the - django settings object. + b) calls apply_settings(), passing in the Django settings """ -from . import provider - - _FIELDS_STORED_IN_SESSION = ['auth_entry', 'next'] _MIDDLEWARE_CLASSES = ( 'third_party_auth.middleware.ExceptionMiddleware', @@ -53,25 +17,7 @@ _MIDDLEWARE_CLASSES = ( _SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/dashboard' -def _merge_auth_info(django_settings, auth_info): - """Merge auth_info dict onto django_settings module.""" - enabled_provider_names = [] - to_merge = [] - - for provider_name, provider_dict in auth_info.items(): - enabled_provider_names.append(provider_name) - # Merge iff all settings have been intialized. - for key in provider_dict: - if key not in dir(django_settings): - raise ValueError('Auth setting %s not initialized' % key) - to_merge.append(provider_dict) - - for passed_validation in to_merge: - for key, value in passed_validation.iteritems(): - setattr(django_settings, key, value) - - -def _set_global_settings(django_settings): +def apply_settings(django_settings): """Set provider-independent settings.""" # Whitelisted URL query parameters retrained in the pipeline session. @@ -115,6 +61,9 @@ def _set_global_settings(django_settings): 'third_party_auth.pipeline.login_analytics', ) + # Required so that we can use unmodified PSA OAuth2 backends: + django_settings.SOCIAL_AUTH_STRATEGY = 'third_party_auth.strategy.ConfigurationModelStrategy' + # We let the user specify their email address during signup. django_settings.SOCIAL_AUTH_PROTECTED_USER_FIELDS = ['email'] @@ -136,30 +85,3 @@ def _set_global_settings(django_settings): 'social.apps.django_app.context_processors.backends', 'social.apps.django_app.context_processors.login_redirect', ) - - -def _set_provider_settings(django_settings, enabled_providers, auth_info): - """Sets provider-specific settings.""" - # Must prepend here so we get called first. - django_settings.AUTHENTICATION_BACKENDS = ( - tuple(enabled_provider.get_authentication_backend() for enabled_provider in enabled_providers) + - django_settings.AUTHENTICATION_BACKENDS) - - # Merge settings from provider classes, and configure all placeholders. - for enabled_provider in enabled_providers: - enabled_provider.merge_onto(django_settings) - - # Merge settings from .auth.json, overwriting placeholders. - _merge_auth_info(django_settings, auth_info) - - -def apply_settings(auth_info, django_settings): - """Applies settings from auth_info dict to django_settings module.""" - if django_settings.FEATURES.get('ENABLE_DUMMY_THIRD_PARTY_AUTH_PROVIDER'): - # The Dummy provider is handy for testing and development. - from .dummy import DummyProvider # pylint: disable=unused-variable - provider_names = auth_info.keys() - provider.Registry.configure_once(provider_names) - enabled_providers = provider.Registry.enabled() - _set_global_settings(django_settings) - _set_provider_settings(django_settings, enabled_providers, auth_info) diff --git a/common/djangoapps/third_party_auth/strategy.py b/common/djangoapps/third_party_auth/strategy.py new file mode 100644 index 0000000000..1d5134c6bd --- /dev/null +++ b/common/djangoapps/third_party_auth/strategy.py @@ -0,0 +1,34 @@ +""" +A custom Strategy for python-social-auth that allows us to fetch configuration from +ConfigurationModels rather than django.settings +""" +from .models import OAuth2ProviderConfig +from social.backends.oauth import BaseOAuth2 +from social.strategies.django_strategy import DjangoStrategy + + +class ConfigurationModelStrategy(DjangoStrategy): + """ + A DjangoStrategy customized to load settings from ConfigurationModels + for upstream python-social-auth backends that we cannot otherwise modify. + """ + def setting(self, name, default=None, backend=None): + """ + Load the setting from a ConfigurationModel if possible, or fall back to the normal + Django settings lookup. + + BaseOAuth2 subclasses will call this method for every setting they want to look up. + SAMLAuthBackend subclasses will call this method only after first checking if the + setting 'name' is configured via SAMLProviderConfig. + """ + if isinstance(backend, BaseOAuth2): + provider_config = OAuth2ProviderConfig.current(backend.name) + if not provider_config.enabled: + raise Exception("Can't fetch setting of a disabled backend/provider.") + try: + return provider_config.get_setting(name) + except KeyError: + pass + # At this point, we know 'name' is not set in a [OAuth2|SAML]ProviderConfig row. + # It's probably a global Django setting like 'FIELDS_STORED_IN_SESSION': + return super(ConfigurationModelStrategy, self).setting(name, default, backend) diff --git a/common/djangoapps/third_party_auth/tasks.py b/common/djangoapps/third_party_auth/tasks.py new file mode 100644 index 0000000000..7466e113af --- /dev/null +++ b/common/djangoapps/third_party_auth/tasks.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +""" +Code to manage fetching and storing the metadata of IdPs. +""" +#pylint: disable=no-member +from celery.task import task # pylint: disable=import-error,no-name-in-module +import datetime +import dateutil.parser +import logging +from lxml import etree +import requests +from onelogin.saml2.utils import OneLogin_Saml2_Utils +from third_party_auth.models import SAMLConfiguration, SAMLProviderConfig, SAMLProviderData + +log = logging.getLogger(__name__) + +SAML_XML_NS = 'urn:oasis:names:tc:SAML:2.0:metadata' # The SAML Metadata XML namespace + + +class MetadataParseError(Exception): + """ An error occurred while parsing the SAML metadata from an IdP """ + pass + + +@task(name='third_party_auth.fetch_saml_metadata') +def fetch_saml_metadata(): + """ + Fetch and store/update the metadata of all IdPs + + This task should be run on a daily basis. + It's OK to run this whether or not SAML is enabled. + + Return value: + tuple(num_changed, num_failed, num_total) + num_changed: Number of providers that are either new or whose metadata has changed + num_failed: Number of providers that could not be updated + num_total: Total number of providers whose metadata was fetched + """ + if not SAMLConfiguration.is_enabled(): + return (0, 0, 0) # Nothing to do until SAML is enabled. + + num_changed, num_failed = 0, 0 + + # First make a list of all the metadata XML URLs: + url_map = {} + for idp_slug in SAMLProviderConfig.key_values('idp_slug', flat=True): + config = SAMLProviderConfig.current(idp_slug) + if not config.enabled: + continue + url = config.metadata_source + if url not in url_map: + url_map[url] = [] + if config.entity_id not in url_map[url]: + url_map[url].append(config.entity_id) + # Now fetch the metadata: + for url, entity_ids in url_map.items(): + try: + log.info("Fetching %s", url) + if not url.lower().startswith('https'): + log.warning("This SAML metadata URL is not secure! It should use HTTPS. (%s)", url) + response = requests.get(url, verify=True) # May raise HTTPError or SSLError or ConnectionError + response.raise_for_status() # May raise an HTTPError + + try: + parser = etree.XMLParser(remove_comments=True) + xml = etree.fromstring(response.text, parser) + except etree.XMLSyntaxError: + raise + # TODO: Can use OneLogin_Saml2_Utils to validate signed XML if anyone is using that + + for entity_id in entity_ids: + log.info(u"Processing IdP with entityID %s", entity_id) + public_key, sso_url, expires_at = _parse_metadata_xml(xml, entity_id) + changed = _update_data(entity_id, public_key, sso_url, expires_at) + if changed: + log.info(u"→ Created new record for SAMLProviderData") + num_changed += 1 + else: + log.info(u"→ Updated existing SAMLProviderData. Nothing has changed.") + except Exception as err: # pylint: disable=broad-except + log.exception(err.message) + num_failed += 1 + return (num_changed, num_failed, len(url_map)) + + +def _parse_metadata_xml(xml, entity_id): + """ + Given an XML document containing SAML 2.0 metadata, parse it and return a tuple of + (public_key, sso_url, expires_at) for the specified entityID. + + Raises MetadataParseError if anything is wrong. + """ + if xml.tag == etree.QName(SAML_XML_NS, 'EntityDescriptor'): + entity_desc = xml + else: + if xml.tag != etree.QName(SAML_XML_NS, 'EntitiesDescriptor'): + raise MetadataParseError("Expected root element to be , not {}".format(xml.tag)) + entity_desc = xml.find( + ".//{}[@entityID='{}']".format(etree.QName(SAML_XML_NS, 'EntityDescriptor'), entity_id) + ) + if not entity_desc: + raise MetadataParseError("Can't find EntityDescriptor for entityID {}".format(entity_id)) + + expires_at = None + if "validUntil" in xml.attrib: + expires_at = dateutil.parser.parse(xml.attrib["validUntil"]) + if "cacheDuration" in xml.attrib: + cache_expires = OneLogin_Saml2_Utils.parse_duration(xml.attrib["cacheDuration"]) + if expires_at is None or cache_expires < expires_at: + expires_at = cache_expires + + sso_desc = entity_desc.find(etree.QName(SAML_XML_NS, "IDPSSODescriptor")) + if not sso_desc: + raise MetadataParseError("IDPSSODescriptor missing") + if 'urn:oasis:names:tc:SAML:2.0:protocol' not in sso_desc.get("protocolSupportEnumeration"): + raise MetadataParseError("This IdP does not support SAML 2.0") + + # Now we just need to get the public_key and sso_url + public_key = sso_desc.findtext("./{}//{}".format( + etree.QName(SAML_XML_NS, "KeyDescriptor"), "{http://www.w3.org/2000/09/xmldsig#}X509Certificate" + )) + if not public_key: + raise MetadataParseError("Public Key missing. Expected an ") + public_key = public_key.replace(" ", "") + binding_elements = sso_desc.iterfind("./{}".format(etree.QName(SAML_XML_NS, "SingleSignOnService"))) + sso_bindings = {element.get('Binding'): element.get('Location') for element in binding_elements} + try: + # The only binding supported by python-saml and python-social-auth is HTTP-Redirect: + sso_url = sso_bindings['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'] + except KeyError: + raise MetadataParseError("Unable to find SSO URL with HTTP-Redirect binding.") + return public_key, sso_url, expires_at + + +def _update_data(entity_id, public_key, sso_url, expires_at): + """ + Update/Create the SAMLProviderData for the given entity ID. + Return value: + False if nothing has changed and existing data's "fetched at" timestamp is just updated. + True if a new record was created. (Either this is a new provider or something changed.) + """ + data_obj = SAMLProviderData.current(entity_id) + fetched_at = datetime.datetime.now() + if data_obj and (data_obj.public_key == public_key and data_obj.sso_url == sso_url): + data_obj.expires_at = expires_at + data_obj.fetched_at = fetched_at + data_obj.save() + return False + else: + SAMLProviderData.objects.create( + entity_id=entity_id, + fetched_at=fetched_at, + expires_at=expires_at, + sso_url=sso_url, + public_key=public_key, + ) + return True diff --git a/common/djangoapps/third_party_auth/tests/data/saml_key.key b/common/djangoapps/third_party_auth/tests/data/saml_key.key new file mode 100644 index 0000000000..a6b7f7fa85 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/data/saml_key.key @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICWwIBAAKBgQDM+Nf7IeRdIIgYUke6sR3n7osHVYXwH6pb+Ovq8j3hUoy8kzT9 +kJF0RB3h3Q2VJ3ZWiQtT94fZX2YYorVdoGVK2NWzjLwgpHUsgfeJq5pCjP0d2OQu +9Qvjg6YOtYP6PN3j7eK7pUcxQvIcaY9APDF57ua/zPsm3UzbjhRlJZQUewIDAQAB +AoGADWBsD/qdQaqe1x9/iOKINhuuPRNKw2n9nzT2iIW4nhzaDHB689VceL79SEE5 +4rMJmQomkBtGZVxBeHgd5/dQxNy3bC9lPN1uoMuzjQs7UMk+lvy0MoHfiJcuIxPX +RdyZTV9LKN8vq+ZpVykVu6pBdDlne4psPZeQ76ynxke/24ECQQD3NX7JeluZ64la +tC6b3VHzA4Hd1qTXDWtEekh2WaR2xuKzcLyOWhqPIWprylBqVc1m+FA/LRRWQ9y6 +vJMiXMk7AkEA1ELWj9DtZzk9BV1JxsDUUP0/IMAiYliVac3YrvQfys8APCY1xr9q +BAGurH4VWXuEnbx1yNXK89HqFI7kDrMtwQJAVTXtVAmHFZEosUk2X6d0He3xj8Py +4eXQObRk0daoaAC6F9weQnsweHGuOyVrfpvAx2OEVaJ2Rh3yMbPai5esDQJAS9Yh +gLqdx26M3bjJ3igQ82q3vkTHRCnwICA6la+FGFnC9LqWJg9HmmzbcqeNiy31YMHv +tzSjUV+jaXrwAkyEQQJAK/SCIVsWRhFe/ssr8hS//V+hZC4kvCv4b3NqzZK1x+Xm +7GaGMV0xEWN7shqVSRBU4O2vn/RWD6/6x3sHkU57qg== +-----END RSA PRIVATE KEY----- diff --git a/common/djangoapps/third_party_auth/tests/data/saml_key.pub b/common/djangoapps/third_party_auth/tests/data/saml_key.pub new file mode 100644 index 0000000000..e93f6dd59b --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/data/saml_key.pub @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICsDCCAhmgAwIBAgIJAJrENr8EPgpcMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTUwNjEzMDEwNTE0WhcNMjUwNjEyMDEwNTE0WjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB +gQDM+Nf7IeRdIIgYUke6sR3n7osHVYXwH6pb+Ovq8j3hUoy8kzT9kJF0RB3h3Q2V +J3ZWiQtT94fZX2YYorVdoGVK2NWzjLwgpHUsgfeJq5pCjP0d2OQu9Qvjg6YOtYP6 +PN3j7eK7pUcxQvIcaY9APDF57ua/zPsm3UzbjhRlJZQUewIDAQABo4GnMIGkMB0G +A1UdDgQWBBTjOyPvAuej5q4C80jlFrQmOlszmzB1BgNVHSMEbjBsgBTjOyPvAuej +5q4C80jlFrQmOlszm6FJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUt +U3RhdGUxITAfBgNVBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAJrENr8E +PgpcMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADgYEAV5w0SxjUTFWfL3ZG +6sgA0gKf8aV8w3AlihLt9tKCRgrK4sBK9xmfwp/fnbdxkHU58iozI894HqmrRzCi +aRLWmy3W8640E/XCa6P+i8ET7RksgNJ5cD9WtISHkGc2dnW76+2nv8d24JKeIx2w +oJAtspMywzr0SoxDIJr42N6Kvjk= +-----END CERTIFICATE----- diff --git a/common/djangoapps/third_party_auth/tests/data/saml_key_alt.key b/common/djangoapps/third_party_auth/tests/data/saml_key_alt.key new file mode 100644 index 0000000000..d54d58a3b6 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/data/saml_key_alt.key @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMoR8CP+HlvsPRwi +VCCuWxZOdNjYa4Qre3JEWPkqlUwpci1XGTBqH7DK9b2hmBXMjYoDKOnF5pL7Y453 +3JSJ2+AG7D4AJGSotA3boKF18EDgeMzAWjAhDVhTprGz+/1G+W0R4SSyY5QGyBhL +Z36xF2w5HyeiqN/Iiq3QKGl2CFORAgMBAAECgYEAwH2CAudqSCqstAZHmbI99uva +B09ybD93owxUrVbRTfIVX/eeeS4+7g0JNxGebPWkxxnneXoaAV4UIn0v1RfWKMs3 +QGiBsOSup1DWWwkBfvQ1hNlJfVCqgZH1QVbhPpw9M9gxhLZQaSZoI/qY/8n/54L0 +zU4S6VYBH6hnkgZZmiECQQDpYUS8HgnkMUX/qcDNBJT23qHewHsZOe6uqC+7+YxQ +xKT8iCxybDbZU7hmZ1Av8Ns4iF7EvZ0faFM8Ls76wFX1AkEA3afLUMLHfTx40XwO +oU7GWrYFyLNCc3/7JeWi6ZKzwzQqiGvFderRf/QGQsCtpLQ8VoLz/knF9TkQdSh6 +yuIprQJATfcmxUmruEYVwnFtbZBoS4jYvtfCyAyohkS9naiijaEEFTFQ1/D66eOk +KOG+0iU+t0YnksZdpU5u8B4bG34BuQJAXv6FhTQk+MhM40KupnUzTzcJXY1t4kAs +K36yBjZoMjWOMO83LiUX6iVz9XHMOXVBEraGySlm3IS7R+q0TXUF9QJAQ69wautf +8q1OQiLcg5WTFmSFBEXqAvVwX6FcDSxor9UnI0iHwyKBss3a2IXY9LoTPTjR5SHh +GDq2lXmP+kmbnQ== +-----END PRIVATE KEY----- diff --git a/common/djangoapps/third_party_auth/tests/data/saml_key_alt.pub b/common/djangoapps/third_party_auth/tests/data/saml_key_alt.pub new file mode 100644 index 0000000000..1221357e6d --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/data/saml_key_alt.pub @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICWDCCAcGgAwIBAgIJAMlM2wrOvplkMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTUwNjEzMDEyMTAwWhcNMjUwNjEyMDEyMTAwWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB +gQDKEfAj/h5b7D0cIlQgrlsWTnTY2GuEK3tyRFj5KpVMKXItVxkwah+wyvW9oZgV +zI2KAyjpxeaS+2OOd9yUidvgBuw+ACRkqLQN26ChdfBA4HjMwFowIQ1YU6axs/v9 +RvltEeEksmOUBsgYS2d+sRdsOR8noqjfyIqt0ChpdghTkQIDAQABo1AwTjAdBgNV +HQ4EFgQUU0TNPc1yGas/W4HJl/Hgtrmdu6MwHwYDVR0jBBgwFoAUU0TNPc1yGas/ +W4HJl/Hgtrmdu6MwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQUFAAOBgQCE4BqJ +v2s99DS16NbZtR7tpqXDxiDaCg59VtgcHQwxN4qXcixZi5N4yRvzjYschAQN5tQ6 +bofXdIK3tJY9Ynm0KPO+5l0RCv7CkhNgftTww0bWC91xaHJ/y66AqONuLpaP6s43 +SZYG2D6ric57ZY4kQ6ZlUv854TPzmvapnGG7Hw== +-----END CERTIFICATE----- diff --git a/common/djangoapps/third_party_auth/tests/data/testshib_metadata.xml b/common/djangoapps/third_party_auth/tests/data/testshib_metadata.xml new file mode 100644 index 0000000000..e78b2e1733 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/data/testshib_metadata.xml @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + testshib.org + + TestShib Test IdP + TestShib IdP. Use this as a source of attributes + for your test SP. + https://www.testshib.org/testshibtwo.jpg + + + + + + + + MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwJVUzEV + MBMGA1UECBMMUGVubnN5bHZhbmlhMRMwEQYDVQQHEwpQaXR0c2J1cmdoMREwDwYD + VQQKEwhUZXN0U2hpYjEZMBcGA1UEAxMQaWRwLnRlc3RzaGliLm9yZzAeFw0wNjA4 + MzAyMTEyMjVaFw0xNjA4MjcyMTEyMjVaMGcxCzAJBgNVBAYTAlVTMRUwEwYDVQQI + EwxQZW5uc3lsdmFuaWExEzARBgNVBAcTClBpdHRzYnVyZ2gxETAPBgNVBAoTCFRl + c3RTaGliMRkwFwYDVQQDExBpZHAudGVzdHNoaWIub3JnMIIBIjANBgkqhkiG9w0B + AQEFAAOCAQ8AMIIBCgKCAQEArYkCGuTmJp9eAOSGHwRJo1SNatB5ZOKqDM9ysg7C + yVTDClcpu93gSP10nH4gkCZOlnESNgttg0r+MqL8tfJC6ybddEFB3YBo8PZajKSe + 3OQ01Ow3yT4I+Wdg1tsTpSge9gEz7SrC07EkYmHuPtd71CHiUaCWDv+xVfUQX0aT + NPFmDixzUjoYzbGDrtAyCqA8f9CN2txIfJnpHE6q6CmKcoLADS4UrNPlhHSzd614 + kR/JYiks0K4kbRqCQF0Dv0P5Di+rEfefC6glV8ysC8dB5/9nb0yh/ojRuJGmgMWH + gWk6h0ihjihqiu4jACovUZ7vVOCgSE5Ipn7OIwqd93zp2wIDAQABo4HEMIHBMB0G + A1UdDgQWBBSsBQ869nh83KqZr5jArr4/7b+QazCBkQYDVR0jBIGJMIGGgBSsBQ86 + 9nh83KqZr5jArr4/7b+Qa6FrpGkwZzELMAkGA1UEBhMCVVMxFTATBgNVBAgTDFBl + bm5zeWx2YW5pYTETMBEGA1UEBxMKUGl0dHNidXJnaDERMA8GA1UEChMIVGVzdFNo + aWIxGTAXBgNVBAMTEGlkcC50ZXN0c2hpYi5vcmeCAQAwDAYDVR0TBAUwAwEB/zAN + BgkqhkiG9w0BAQUFAAOCAQEAjR29PhrCbk8qLN5MFfSVk98t3CT9jHZoYxd8QMRL + I4j7iYQxXiGJTT1FXs1nd4Rha9un+LqTfeMMYqISdDDI6tv8iNpkOAvZZUosVkUo + 93pv1T0RPz35hcHHYq2yee59HJOco2bFlcsH8JBXRSRrJ3Q7Eut+z9uo80JdGNJ4 + /SJy5UorZ8KazGj16lfJhOBXldgrhppQBb0Nq6HKHguqmwRfJ+WkxemZXzhediAj + Geka8nz8JjwxpUjAiSWYKLtJhGEaTqCYxCCX2Dw+dOTqUzHOZ7WKv4JXPK5G/Uhr + 8K/qhmFT2nIQi538n6rVYLeWj8Bbnl+ev0peYzxFyF5sQA== + + + + + + + + + + + + + + + urn:mace:shibboleth:1.0:nameIdentifier + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + + + + + + + + + + + + + + + + MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwJVUzEV + MBMGA1UECBMMUGVubnN5bHZhbmlhMRMwEQYDVQQHEwpQaXR0c2J1cmdoMREwDwYD + VQQKEwhUZXN0U2hpYjEZMBcGA1UEAxMQaWRwLnRlc3RzaGliLm9yZzAeFw0wNjA4 + MzAyMTEyMjVaFw0xNjA4MjcyMTEyMjVaMGcxCzAJBgNVBAYTAlVTMRUwEwYDVQQI + EwxQZW5uc3lsdmFuaWExEzARBgNVBAcTClBpdHRzYnVyZ2gxETAPBgNVBAoTCFRl + c3RTaGliMRkwFwYDVQQDExBpZHAudGVzdHNoaWIub3JnMIIBIjANBgkqhkiG9w0B + AQEFAAOCAQ8AMIIBCgKCAQEArYkCGuTmJp9eAOSGHwRJo1SNatB5ZOKqDM9ysg7C + yVTDClcpu93gSP10nH4gkCZOlnESNgttg0r+MqL8tfJC6ybddEFB3YBo8PZajKSe + 3OQ01Ow3yT4I+Wdg1tsTpSge9gEz7SrC07EkYmHuPtd71CHiUaCWDv+xVfUQX0aT + NPFmDixzUjoYzbGDrtAyCqA8f9CN2txIfJnpHE6q6CmKcoLADS4UrNPlhHSzd614 + kR/JYiks0K4kbRqCQF0Dv0P5Di+rEfefC6glV8ysC8dB5/9nb0yh/ojRuJGmgMWH + gWk6h0ihjihqiu4jACovUZ7vVOCgSE5Ipn7OIwqd93zp2wIDAQABo4HEMIHBMB0G + A1UdDgQWBBSsBQ869nh83KqZr5jArr4/7b+QazCBkQYDVR0jBIGJMIGGgBSsBQ86 + 9nh83KqZr5jArr4/7b+Qa6FrpGkwZzELMAkGA1UEBhMCVVMxFTATBgNVBAgTDFBl + bm5zeWx2YW5pYTETMBEGA1UEBxMKUGl0dHNidXJnaDERMA8GA1UEChMIVGVzdFNo + aWIxGTAXBgNVBAMTEGlkcC50ZXN0c2hpYi5vcmeCAQAwDAYDVR0TBAUwAwEB/zAN + BgkqhkiG9w0BAQUFAAOCAQEAjR29PhrCbk8qLN5MFfSVk98t3CT9jHZoYxd8QMRL + I4j7iYQxXiGJTT1FXs1nd4Rha9un+LqTfeMMYqISdDDI6tv8iNpkOAvZZUosVkUo + 93pv1T0RPz35hcHHYq2yee59HJOco2bFlcsH8JBXRSRrJ3Q7Eut+z9uo80JdGNJ4 + /SJy5UorZ8KazGj16lfJhOBXldgrhppQBb0Nq6HKHguqmwRfJ+WkxemZXzhediAj + Geka8nz8JjwxpUjAiSWYKLtJhGEaTqCYxCCX2Dw+dOTqUzHOZ7WKv4JXPK5G/Uhr + 8K/qhmFT2nIQi538n6rVYLeWj8Bbnl+ev0peYzxFyF5sQA== + + + + + + + + + + + + + + + + urn:mace:shibboleth:1.0:nameIdentifier + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + + + + + TestShib Two Identity Provider + TestShib Two + http://www.testshib.org/testshib-two/ + + + Nate + Klingenstein + ndk@internet2.edu + + + + diff --git a/common/djangoapps/third_party_auth/tests/data/testshib_response.txt b/common/djangoapps/third_party_auth/tests/data/testshib_response.txt new file mode 100644 index 0000000000..74def7401d --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/data/testshib_response.txt @@ -0,0 +1 @@ +RelayState=testshib&SAMLResponse=PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c2FtbDJwOlJlc3BvbnNlIHhtbG5zOnNhbWwycD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnByb3RvY29sIiBEZXN0aW5hdGlvbj0iaHR0cDovL2V4YW1wbGUubm9uZS9hdXRoL2NvbXBsZXRlL3RwYS1zYW1sLyIgSUQ9Il9hMDdmZDlhMDg0ODM3M2U1NTMyMGRjMzQyNDk0ZWY1ZCIgSW5SZXNwb25zZVRvPSJURVNUSUQiIElzc3VlSW5zdGFudD0iMjAxNS0wNi0xNVQwMDowNzoxNS4xODhaIiBWZXJzaW9uPSIyLjAiPjxzYW1sMjpJc3N1ZXIgeG1sbnM6c2FtbDI9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphc3NlcnRpb24iIEZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOm5hbWVpZC1mb3JtYXQ6ZW50aXR5Ij5odHRwczovL2lkcC50ZXN0c2hpYi5vcmcvaWRwL3NoaWJib2xldGg8L3NhbWwyOklzc3Vlcj48c2FtbDJwOlN0YXR1cz48c2FtbDJwOlN0YXR1c0NvZGUgVmFsdWU9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpzdGF0dXM6U3VjY2VzcyIvPjwvc2FtbDJwOlN0YXR1cz48c2FtbDI6RW5jcnlwdGVkQXNzZXJ0aW9uIHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIj48eGVuYzpFbmNyeXB0ZWREYXRhIHhtbG5zOnhlbmM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jIyIgSWQ9Il9kYzc3ODI3YmY1ZGMzYjZmNGQzNjkzZWUzMTU2YmE1MiIgVHlwZT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjRWxlbWVudCI%2BPHhlbmM6RW5jcnlwdGlvbk1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jI2FlczEyOC1jYmMiIHhtbG5zOnhlbmM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jIyIvPjxkczpLZXlJbmZvIHhtbG5zOmRzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjIj48eGVuYzpFbmNyeXB0ZWRLZXkgSWQ9Il85NzhhN2I2NDE5YTMxOGQ4NmUzMzE0Y2Y5YjFjOTEzZiIgeG1sbnM6eGVuYz0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjIj48eGVuYzpFbmNyeXB0aW9uTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjcnNhLW9hZXAtbWdmMXAiIHhtbG5zOnhlbmM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jIyI%2BPGRzOkRpZ2VzdE1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNzaGExIiB4bWxuczpkcz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnIyIvPjwveGVuYzpFbmNyeXB0aW9uTWV0aG9kPjxkczpLZXlJbmZvPjxkczpYNTA5RGF0YT48ZHM6WDUwOUNlcnRpZmljYXRlPk1JSUNzRENDQWhtZ0F3SUJBZ0lKQUpyRU5yOEVQZ3BjTUEwR0NTcUdTSWIzRFFFQkJRVUFNRVV4Q3pBSkJnTlZCQVlUQWtGVk1STXcKRVFZRFZRUUlFd3BUYjIxbExWTjBZWFJsTVNFd0h3WURWUVFLRXhoSmJuUmxjbTVsZENCWGFXUm5hWFJ6SUZCMGVTQk1kR1F3SGhjTgpNVFV3TmpFek1ERXdOVEUwV2hjTk1qVXdOakV5TURFd05URTBXakJGTVFzd0NRWURWUVFHRXdKQlZURVRNQkVHQTFVRUNCTUtVMjl0ClpTMVRkR0YwWlRFaE1COEdBMVVFQ2hNWVNXNTBaWEp1WlhRZ1YybGtaMmwwY3lCUWRIa2dUSFJrTUlHZk1BMEdDU3FHU0liM0RRRUIKQVFVQUE0R05BRENCaVFLQmdRRE0rTmY3SWVSZElJZ1lVa2U2c1Izbjdvc0hWWVh3SDZwYitPdnE4ajNoVW95OGt6VDlrSkYwUkIzaAozUTJWSjNaV2lRdFQ5NGZaWDJZWW9yVmRvR1ZLMk5XempMd2dwSFVzZ2ZlSnE1cENqUDBkMk9RdTlRdmpnNllPdFlQNlBOM2o3ZUs3CnBVY3hRdkljYVk5QVBERjU3dWEvelBzbTNVemJqaFJsSlpRVWV3SURBUUFCbzRHbk1JR2tNQjBHQTFVZERnUVdCQlRqT3lQdkF1ZWoKNXE0QzgwamxGclFtT2xzem16QjFCZ05WSFNNRWJqQnNnQlRqT3lQdkF1ZWo1cTRDODBqbEZyUW1PbHN6bTZGSnBFY3dSVEVMTUFrRwpBMVVFQmhNQ1FWVXhFekFSQmdOVkJBZ1RDbE52YldVdFUzUmhkR1V4SVRBZkJnTlZCQW9UR0VsdWRHVnlibVYwSUZkcFpHZHBkSE1nClVIUjVJRXgwWklJSkFKckVOcjhFUGdwY01Bd0dBMVVkRXdRRk1BTUJBZjh3RFFZSktvWklodmNOQVFFRkJRQURnWUVBVjV3MFN4alUKVEZXZkwzWkc2c2dBMGdLZjhhVjh3M0FsaWhMdDl0S0NSZ3JLNHNCSzl4bWZ3cC9mbmJkeGtIVTU4aW96STg5NEhxbXJSekNpYVJMVwpteTNXODY0MEUvWENhNlAraThFVDdSa3NnTko1Y0Q5V3RJU0hrR2MyZG5XNzYrMm52OGQyNEpLZUl4MndvSkF0c3BNeXd6cjBTb3hECklKcjQyTjZLdmprPTwvZHM6WDUwOUNlcnRpZmljYXRlPjwvZHM6WDUwOURhdGE%2BPC9kczpLZXlJbmZvPjx4ZW5jOkNpcGhlckRhdGEgeG1sbnM6eGVuYz0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjIj48eGVuYzpDaXBoZXJWYWx1ZT5sWEEvSGI2SlIxaW1UM2M1citrQU9taHVieVYvOUpqTUNzdkRJYlBEckxVR1g0aWFVbGl6c2d0dkdzRzdYOVpQWUxhc281U2ZlK1dTbVpKeW9tMGc0UU9HOWd6R3FIVGwybzFGMlJib0ZKS2FzaDZoQ011c2dSRmpJWElSUzdvTWJJTGxmcGhvcUN2c0pGdUpKY1FldU9SeWwyZmlrcUJSclhjNmwyMks2YzA9PC94ZW5jOkNpcGhlclZhbHVlPjwveGVuYzpDaXBoZXJEYXRhPjwveGVuYzpFbmNyeXB0ZWRLZXk%2BPC9kczpLZXlJbmZvPjx4ZW5jOkNpcGhlckRhdGEgeG1sbnM6eGVuYz0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjIj48eGVuYzpDaXBoZXJWYWx1ZT5UQWdqL1d3ait2b0ppdHNhUE9IdmFSUHN3WE5oaHhEMmx2Q0t6MXJzQmtYbmJheVhlNmJucE1CMHg5aWpFMVdqOFV4YmtJQmhBMTEwRHhhTjZabkhReUs4amI3U29VU09jQkovUGxrT1NJc21zcS94dTVZTDJrdC9qdTdwRDR6K3BpcUlJbWRSVXdkcEhPdXFSVUxzUUJNU1NCRU5QT2sxQkRjcWZxb091N1g1VFIyOHE2eEwwUlFoV2NqMHBoTW9BMHdZS2ZFU0tacmRDQVRLcXVFcUJTRGJzRThNekh1cndiUkVkcjM5YnpBMGRNTm1zVFBFWmpsdkdqSEJmdVRaS3VPWXBoR3lFV1FVa29PbEtMQ1ROQ3VQYnhaWjFZNi80SGhlTGpQL3pnSU9ieWdLVkdadmNBSWpyTmZXNi9iQkJ0MVJjbndKV3pTZU5XR3hnd2hnZTJDank4RVFTUHJxbWZYK29VSVY3NExmT1NhV2JONXRwK3dtbzgveGlzWFErRysyZzA1ZnBiZEVmcEhacFZhMmFZY24xYWRtYy9uK3p1U0w2LzRTVWtXcTIvRHpRTTBWQVNQN2o0MXNIc0wwZEt2ZkhiQk5BcFB3NzdyTWdXRkwxcjVRajZiUmVjSElkQ3ozamZLcDZtdzBURzFQaUxCc2FXZ2wzYnZVR0lJNmxOWTg1TStFWHpoUnNCY29uVjgwUWFnKzBoTks3OWNteFkvOFByOGZkdXErMmgva3UxUzN2REo0cTlpR0FPaG1MUUFrWEQwWGF5Q1hXa0x1MEFyTmdOalNjaU55UXY2c3RmOUx1NGdUd1lKWUsyV0lWYUEvTzdMbUl2WmxkL0thL2VSaHdJQnV5YnpmemxWSVA0a2VVVmpldDVZMnZUVzA5bG02bWQzV1dmOTYrb21yNGVBQjllWjBmaFRxRkV4UzJkQVFqWi9JV21ZNnNXQXJCSkg4aEhya0IxdVBrQkZYTVFNbSs5T3ZKMWpnWXZtdVJUcHZzc1ZlL1MwWDhIVlVSdUpZVDVBY09YNHhuTWw0QkpXdnlLbzFFUms4bmRZOXJTTkJscGUrSXRUQXY5ZDgveVdJcGlYRVRLWGpKbTlVVWt6NnIyclhDUkN0VDhWa3J2M29jZmY0Z09nUjlTSWR0b21hU2w5RG9sTjVFL0ljTm1DUkhrbTR0U1J4bDNrNzNiMy8rNUxJNDhHc3l2c1VsNlNaZ1dCNmhSdDZaNDNuS2Iza3czOWd1cjk0VStrcURuUndub3lyeDVoc3N1NWRVVFNURkJPajhENGk0SjZwYi9sQ3FvWDVtR3lNODVJWnpxQk1Sa3IzeldPUzg3SVhBQm1uVDZ6aE1NdEpzN3EwRG9WZUhQbExUeUlVRHFMZWlaMjREQzN3Z1BjNmh0STVNeU1EZG5OM0hLczdnT3lhY3ppV280c3I1RGJCc3FqaFd1ZzJIanJEQ2hXZUszY1NuTlcwbkZCT1RJOFZMSFJXK2lhd3ZJWnUwZGdSenR1aEFjQ3lCelB0SHUrSzZlZmgyR0lNT0NhVVJCUzA3eGRETkw2MG9jOTBZZUlmOGV5WDFqV3ZDaDJZSnNxMDRtU3AySUFFQzFQSS9mc3lleGIrU2lMWTNzR1FyNEQ1bGx6ZHh6VEpKa2tYd2c4dHMxeklvSEx2TDdkQ29BRVNmNDJVVERFdWo4bGtadjYrM0JkNkwrM3Z6WFFPb2xwQ1kxeGRodVFOQjhsbG5KWHRNMmdtb1NEcmRwN1JzZWtTM2hkOWkxSkxkYWR1bW9oM0hmd0x6d0c4SkpCRkpmb3k0cGtzM05vMWFyT0lKRVBzZ3ZCMzZZUitScFFvc2hHM01PS0EwTUphcDNJbE5yQlVjdmw4WW5jSGkzbE9CUWpySFcrSURVVHhxZG9jdmw0NTBscmVUdkVoZXYraUFLU0J4alhNclNDQytaOWFIS1BJR2s4UzR2dWtudURIMUpsdTNLSFBwNFA5bnhSMnp3V3ZldktkYjNLdjZ3emhidmlxR0pPY05CeUZzRVJvNkEwSVVNVHQvOFJkQzJkZndyTjNBTHZ4dktrTmE0c3dEcElqYkRkb3RrZWgzT29FTVRETGE4M1R6Ym9ROFdWcDJLbEJiZDlWdXVNRm8xVUtrN2Q0dFh4VXRnWkZ3YlZQUWNzU21TV1dud1QvMFhxdDB0Yk8zQ0lnNkVZOHFXaUxoWU5naGw4eUhXTGIxbUUzVWNoSUhhVDBoWmNXTXVLMmtNbVpNMlJJeWVJY3o4M2NmY2lSWFJUb0g2TzFPenF5ZytBVUZiendibXQxR2d3V1hnMTdZQVJxSFViaDZwdktUMGJFRzdPUDRMZ0U4ajQvdTJoQjFraUFnUXpQSC84RHVCQlQwdkdOeElnKzhNWi9ObUFmUWRxODBlcVNQTDVpczlGbkZVei9GUzYzdEVCb0xYZnk5VzFRUjBsZ3VUY3o5b3l1dkdPdmVDZUt4RE9pZU9ZSktFTTlhVFVzVjRGc2c5M0NzMTQvRkhXQzJpclRKN1J3Ymg4eG1WUzBqUnVFQUdBejdETDNaSkFiRElLUUM5ZWZ2QUYxRHREVjZiVEI5cnNlczdsOWlDS3RnSnRWRE83Nmc5M2tWSGwvdXpYbVhwQ0NveS9XYStvVFErek5WL0lMbXAwVnorRnhJRHROUmcyL0V3eENyN042RW05ZmRibHgrMjYyWkdEVWVKcXcwNjB3L0RDTThiMDJmc2dyQTJ4NUVvcjF4a0tmcXMvaFMvVlo5azlVRHB0ZVFaMWdrSEU5TEN6TzY1azFRRTNENDNRK21ReFM2cGRJL1BpOUUxVkRoN3pMemdpOUVscXFLVHZNYjhPQUxmdSthTStPWlFaUjN4L21UVjQycTNmQlZya2lYRHpNd0pkclhjNW5UcmxLSDJTSUZsV2JaUzNXK0tHUVpoVzJ4dVRzdU9yS1FiVDY2OEVRbEpNTEhuckxLQTFrV0NvalJKV1hqZGFxSGVrWURDWjlFcDZaRnZ4NjJzWlRTdWMrVlNjK3UxSnZjT0w3NTZzNEFFWnNjR3ZnbXNxbUx5MldYOHpLbGw2MEdVUlE0YlBHMHd3YVUxRGF3dnJTejZqTCtyUjVBTTlGdTFlQi9WaUZSVVo4R0prR0VIOTdRWmNKcjV6RFpXNnZkOVBZRnlTd0Zqa21rOWxuZ1NFdFNPZFFITmZTZXRxbXZrcS9HbTB2enlTSWVyM3N5OCszR2IzNjJHaHd5MFNCekp6dEdHM0dQWTE3NUxRK3FWcGJCc1Y0MmgvOExPcnFaUGpVY0RrUk1NMU1LTHBtOEpSeVRGeWM5c0NxZ1lVMzNBbTByRzlkTHgxeVVKaTkwUncxc1pDa0lYUFIxYWFRUlkyNFEyekhVNjNBNHZDQVVaaVBOSHdVZlI4Qzk0cDdDeks5UTlreFpoaGZ6bjRBOFdadVZMSjkvWWI3d1RmNWQvNlNmNVFXWUM2anAvbUVWMDAxRnVBZnUzcUZNNmNuNXpXV2xkR2tjaU5RcUJ3SmZoWk9oUnk1VjBEaW5rMDBjSVlncDFmVnVnWHFkR1grV1ZCQkJ5M29va0toYW05S2RPc1N6aEI4NXZyN3h6R2JsREVXVGhFN0F5U3duRUVVNnBjcXpxR1E3Mk9KLytWS2I4ZVNPVWxzQW1LZnZ0czgzTHBYR2o0dkdRR1UwNVptK2grakdWeEpjMTJSQW5lbUhYK1FiNVhJdGk4ek1CazJkT2I4NUVPRUlvVnduWXpmSmhqQmtpOFhYMUtWaTVWbDE5dmV4OExxQ2pLdW9JeUsrSFpVQWtGMmpqY01WenUybXEvM3JPblJvTUhqVEszbFpGZm53S1E3WUxqd2dlVk45QnBmNm1Zem5Bb1RhVG1kQTUza29ocnMrVExuK0toUEpCRFc3Rml6L0ZDbVhzU2dJQ2tQcHAzVnJnQkY0N1ZDUEtPQi9yR2hPaklKd0V2bjgvZ0o1MU5qSmY3NkI4OUxHKzhLOXZpV2ZCeDRvMGxIczZLRmtCSk5RSTF4TCtVRGREWThURitlNXFtaVg3TTh6QmVsQjJlalZKaW9DRkhUcG9mRlZyT0kzTTlGcUk0Mk9KYVdrQytFTFJCaHR6dmJxaisrMWNOdlArcXVKQkRseTZNY2d3SE5BbzlhOHZIcjNjTmRHdmdjVGNFemx1aHpXOW9wb0dSTERPbHRUT0RqOUNQeUVXU2VablFxV2pHRGZiZkRkWm85bTRTWmUxTjQzNUNZYzJBY2VtR3JDdjAySUhyNmgvR1dZMTRFRlJ6T3crTFQ0Skg2TDBzM2w5V1JlZVlvR2NJc2RxYmhrN1Y0OXF5b1lBKzRlb3IvUi9VRzhZaFFYVzJJckQxaTVveDRGTEtXa0Q1UWtKRWU0VmpyNUVRTTBNTHJzNnE4YW5URklITGV3YXE4V0lnanJLS2FtRWloU2tGK2RnMHdScllTeUpuSzdySUsxWi9GQTlPakFUeGlIN0Y1TWcraGhlbXozYlFrU1FTaGN2T1lVSEdjU2sreHQrMXBuSG1lc1ZZTUlCb2d4S3JkUy9yLy9LM0lxdWR1Wko1bE5oVElXZ3dISkpiTVBHTG9mQUJybTlwZUFCVG5mVUFkcDIxK08wQmluVjdYZVg4RXFmVGhVejhrY2Z0MmgrSW5hajZwd1lDdGJ2MmE4dld5UXFKM0haNnBiZHV3bUJFVzhMa3Y2Qm44Yng5TllZMTdyMDliWDNCbmF6QWpGVW1EN3l6R3pLSnR2c1ZVOUw0RmMyU1B4ZUpQVjluQy91c0ZKc1ZlTEFMMTdqVERyV1k3NXhRdkpDVkJGNFlIS0JHaiszSk5WSWRudElOam5DTWhRak1CZU02V29RRkcyei9IQU9hU1lnaXlJSnlaNzd4MW1UYUtuTkNvSTlzZ3JNZkJzUE9mTXJUbkVGbTR5SThERjFGSTh4VnVONnpBcWp2dGkzNVczRGIzdG9Wa0pYVWk0OStKZzIxRWwxSkNqcjJoVmhBQjN5dE5kV2VnTXlTSFRnY0tRVVdRQmt3WUlEbUphdjY5am9udE9RdWpVbGFIM0lBRExHMUpPSWxuREduN0F2OUhkb0JzdnA3MDdLN20xZmJwOUxZK3NCdkwzcXdNbmpZbGhuZHBmYjdVbGxEajl2SCtuNDE5Z0FWMU9GUkRmVHVkVkRpdEFlQzI5ZWRjSmFGZXBYbmpKeHpvTzNqZHFrVTBiMWRmeEo2T1BCa01XSzJKcDZqTmllVlF6emwzRGJWMnRjcTNpekhQVmRySVZ2eEFqVWl3eENWK1VLTzZmMmlXaG9jQjhsWWE5U2xPOTRxd1Y2SkxSbDlIU3pFbDZtQUdRKzRCaW90aEhleDd2ODlGYnJ3eW00UjkwOGl4cU5odzNCc25wcHkyVzhlQXJtcENxMTRHdjlpM3R4em1mS1c3allIV2xWT0JQZFdoSnQ5NTZWbmliV2pWaVBBME9WOVNRWFZ6L2tpSit1WnZzT0FPY1h0YVRDaTZQL0dDMHJyRmhLc1paQW82ZE5paUF0N1BtVzduQjc5RDU5SzRBd3RNaW5iV016TjRQQzFGcHA5eklQTlEvU0laY1IwN0FjMnJ0ODdoQ0JPNUNIY0xhL2EwZDcxZDNZenEzNFlSWDZRYmdRY2taVytLN0FTbGpUcnFQczBHUXo3eFVRVjY1SnRBUTZvbjBxWGwwQ3drdUFCR0gvVVZ3TXpTalBuSnh5WmhQczN5NFhmdTVOUWhabkQvWWNtenR2Y0tkanBvMExSZzkxZnN3QjQybS8vL282VTd5K1hJNGlXMDMxRFQ0R3ViVWV4NW5mZTByWjdlRTRMaGJkaWJiWVRkTDFESHJuTGhYUVpDaEFRSno5SVV1OWYweEtMY3ovR0lubzRQRC9VK2hKOVkrb3FtelpGM3NhVXpKNTRJY1lGeEVROGo5L25nOTVpWHc0SWM5aXovZmY0Wm9hN2hJMTUzalBMNHFOcS91akphYXd2MXpxRlFhRVJYZUU1UyswZTMwaC9UKzByMmMxMTJkYndZdXljN2UwNi9RVnJpckdSRnQyZnRHUFN2VVRMdTJKVzRBUHVDN1NpNmpYWjQ1QlArenJTNzJteWdQQm9LTSt6N2RYTWMySmhQTnhQcmlGRVJlUmJnQlFFM1RSckpMSVpaR3NPczNJbFdBdGpLRTJaVFd0bjNJQmFQM0loY2sySFJ4MnRXRDJYRnYweTQ1bXhlQnkwMXY0cjAwQjJnMW9JVlYvZkgvajZUQnFLU2VENjBWRGZ3OXMxeXU1VUVhbzRicy9oZmFjWVpZZzg1Y2daS1QrTkZCeHBadG84M3E3bzRBeUNMQTl5dzQ2ZFRzdUlSYnBsMC81MVp0R0hDa29YMkdOa0JRc2pFWTVSNHVoZGRJNnBFSkxuaWNvVnpGS0dsTFErZDJMKy9odnBldHUxMGFTVHhEMmJqWlMrUVlHM0VLZ0VvbDZveThVTHl4bjRMb041bk1zV2d3N0p6NDJWT0V1ZHFENHY0ZVFUdXB0NkRpQzhvRzVzaVdWZER2amZpUHdwN1l5cEFZOE8reFBLOVgzWkU2bVR6aEV6TGxud0Jtc1RuQWpjMTRsYTVha1FBdHJRaXZIY1Vmb1pCcWtQKzFqTmdOZ0lYQlB3WlRBMHh5aEVuYlRMK3JPd3dzcUEycjdMbUlTaFpNa3V0cUQzdC9GbmNBTCtkaTJvQ2pBOVRNdnVwMnRqNm5FaXNwbXpYcmE1WWVnUlVjOE5UNjJxL1dXU25aKy9pb0hUMWFjeldJWG9sN1JhZ0VlQlRlMkFlVVEvam5wd2RwR3gzUldLeGIrajdtN0RuaVpoSmlzZUw5a2JleC9RQUFBV1VxajRBQldtdEptQ1QrUy9KVit6R2FGaU4ybXFzcUd0Mm9qN2w1UzRmMkozcXdxaEpNbXVDRGlteUpzQ0FzOVlNQVFmOXRFRVNSa3pKTy9wOHdEamlFbGRZRUdMNkl2RHJkWHFERzhSTGJkQnczTHJxeUJQOEYwU3lzWmlHYWdXY3BSZGY4NmJKdXgra2gxOXo4eldvSWM4OUVBK2JCVm9ON2M5TEFETEFPbzlYY0pqdllJcFRiWXlJTk1iOVpCRGZlb3d5ZkViZ2Q2bGxiK004MzB5SXFIWVEvaFl0dWpWaDhXazdseG1mMjJzL280eUp3aHNYQlV2OWVWQkFyTDVmbUxPM3NjRmdBejRsR2cvbEN2cThSS0JRaFFYd29CNTM2aDdkRWFnWGxqQk41WGhZcTVmNkZhK0xRRkFIRnlyM2VHT3RZNjJsU3NMUE9Sa2VGQXl6RTY3bk1BZ3lrb1hEUHRrUE9hd2x6bW4zdEVOT3FMMDVRWmEzTkFvZ1RtYVZLYmlUTk1RVVF5M2JQNElYdDE0RlNEa3pweU5pREptcE5yeDJ3cjlVNmJvT08xQTl5eHFMZ1ZHdVNXc1E5Y1ByTm4zV2VZVXJvTjhhZkk4b2w2bFNuODgxaEUrU2I2OU9lZ2UxY09RYUduTUJ4WlpiVThzVDJxd0JsVWFzc0dYWlBOMUU5b3M3UUh5bzJKcmxkU0xrWGpsWWpxV3I2SkJtYmxiRXpRWXg4clA0TDVDUG5RME96WjA0MDV6MklUZXBzSUpHMW90Wng4VmIyWGwvSDNqajNja2F0TUZLU1ZrNTdBQkVIRUp3a3pmbXB4Y3Z0SzBjK081MGpPZUpTOGpJRUhnZUJJSkJTNzRRb0F2VGcvbW5NeTVHVTRGYllHSHZHMDBzYXdBbkNVbW90cm13R2dzcGhkM2orNFNuYm9HZ2Mvd0NzN2FTM0NJcHBBbC8wNTFCbWZhYk8rV2JpRDl4c3dzaElxSjZua3RMSk9mWmM0NjZVZmVpTkVJeG04cU5yQncxTlBrc1lXOWtjK2FtM3diTk1PMVp0N3hIYXU1M01odDVFRExIUjZ6a0wwcGFjY0ZuSndCNXFsS1hSN01Lb3huSmo5VTQyS1FOTFRQditUL2NlNG5oS2dIc2dua00wbU1YelF6Tkx0MDBDdkdGWDRuNnE4Q1JCRnY2SHFSVVdwWGFGdWlrc3Mwa1M3RDc0TThTQ2VnTmRuWit4Z3BLQ0IzOWZWWDEvYmMwUjlDbWN4M050d0hFYXNkZllpUlRIcFI0bGJndG1RUUcrYWNTdVhJWVRaSWFTRzlLWlZVZ3oxeFI4TVhMQXAveDRHbGMzaGNCMURnZ0cybE82RnNBTUhBTDExU3NySk1RUmZsUmxJOU0zNFB0SHRTY2pqSGcwcHRMT3JCOUI4c0NqMEk5bG1aWHU4cW1pMEQzMmg2VC96MnRvOFJMcmExVldZblp5NW9nYmhiQ2E4Sk5JZmNGQmJDTytpUXZlN2xGQ0RXZGJncDZJallGcXR4amdGUmkzVURaMExtQjVqSk9lejA1VWRNT3c3SjFudkhvSDM3RGZFclE5VWZKeUlpRjdGNzR4c2ZMSFIxSXpGenB6YnIyM2QwU3c5OXlCNjlDa1ZtdERCaXo3aWFmaVpXYzBZU2svajkrWDR4NENwU1diOVRMRTY5djI1MStjS0xzZzRPT2ppUHVSYTRnL25oaFB6eGN5bGE4WjNYb2s1ZTdJRy9BbWtCNkEvL29pRFdEKzBzbXJGOUI4VlNnVENiRHRNdFNyUStGUVBzZHBMdG52UndOY0pGcHJEaVVHN25FUTdMQnhoZHhraTZ3dXRuTnY1b2dsUkdheHV4Tk9XYXZaNGUxOTVEaXlVQkN4cjM5MlRjUkxKNExIOE1rTUpnNml2dmlNYllqeTBwSVpLVGJGSjRzOXZ6WlFwVjZVd1FlWEM0TGRWd1E2dlltb05Mb2JCaUlsSE9hUUUxM1hpTEliVTU2aEJVRnJqSHhiNzNRUm1SQ1poeWZpVUNSdUg4YWZjOEpyMFFkMXlJYTJNczd4bU9FczlCWmFwejdTR01OeURXUXlIY0Q5VzgvQ0d1OFlhUldRaWlBQTU4MDRlQWF6MmJnYUNuR0wrNHdZeXpYOXNvWnVGWFZ2eklDbVk3bVF5c1pVMllqUVZoRjdHZWFGTDNKL2ZGTTdqOVdiaThjZG1MNzJENGdLVDVXUmpKTGNVY01HNHNXZTJoNkJncWVybDNJeDFmMzRteGZOSUpFVnlLTTVzVW54a0kvTTFORDU5M2g0UWJGYWVUTjZkc2NLenRrK084OW82Rnhma1dvUFZYRlZJTmF0WFhoSVJHU0cvKzhSQXNNYi81QUZMb0Y5U2x6YytXaWkxQkQ5RGhwZFQvd01ya0lDUWdzVklMbUt4bnhobXZhS3pvcGEyaG9GdXBiL3A2Z2hMQVZpY2RROHZJa3Y4U0hBeGZJc0sxaFZXcHNRMUl0SGw5N3lnSHNtQ0lkR1NJcVNiaGwzdW9CNXhWWWVGV0FObXpYQ3g0OE5oTlc2SnNHZ1c5RXp1NjdlY2Z3VUZlUEZpYXpvbWFPUTFRc3ZjYVhHVGIxWjA0UzdQay9OZmlCWWFJSkoxbGlIMnBaeDhMZGZwZTVuRENtbTlYTVZHbUY2SVdWVjM0QW1uK1B5TlNCcWxzZDZyQlNjWVRISm43ZElUZm9Zd3JvZjZvNVBUR2lwUHNlYXNHbDJoWFpCMWRYc1U0aTJKVDNLenVTeUl1RVU2Y2Z5M242T3duNmNjV0NyUFNLc3Vwb2Z0QithenZpZzd0bUFvRHlFTHN4eUE1dzQrZ0RmQm1BeStwNG02NnVNWjFUWEJiSTZhZ2RzVEpteUtHMm1NNFZrUmo0Z1cvcW0yMDNISno0YlVJN2dlcXNHQktNbGdPR1ZHRDRGZGlPaWxPbkNxeGo5NmFiY0Q5SnVmeVJUYUFmSC9QbGJaaU5mOTVORWtxaXZmNC9CN2lqaEdIZlUwcXFNblJlYVdackFuK2M3RlByc2h6UWIrMC9OUSt1dWRMbGJ6czZHd1IyUG5sNmlNdjRsUFZ3d1UyTWprYzdMUzY5ZzZpSnBZZytsbDM1N0toSWhPUTRpMkNzVXhXWHlVZy9VZCtaQmhSTDFhWWxWQ1NaK1VRZlJodVNCREdkLzNrYjR0azNvUVMzaVgrS09EVFB5RGwxaTUweXhZdjJQZjRPdG1QR1RVYkdUeUJQd1RPUGN4TUR5dDBjWjAzdVdtY0MwblVrMllnWnlLeFFXTTBSaHJVK2pRUVZwL1BWY1NRdGYwREtTSDZzUEh2M1RlbmJMV25sdzNKaUlveEhmRjRJTGZhY255M0xPTGFxcXN1QU95REJmMnQxdlF2UTdkMmtZMGhwdUF4Y091c0I4dXpmdmQyTWtramZwVHFNWGN4TndNbUxWYXRobGVpQUUwRVBFaTFDanZuWVgyQUE5a2pwLy9oRlZaaklvK0ZYRzlQRE5ObVdDTVRPSXQwcmZoRzFxNTFDbG1sQW41Mm1vQ016d0lNOGlLVXk0MDNPdElBQkgrSzhTbHQ4aFpEaHRmRDBoR0xVOXg0TVBaUmhxMmdRd0tCL3Fpd3BnVnNWbDhrNmVxUnZpMjFjeWFJRklIRVQ3L0ljT25zWU1rYnh2azBQdGtIL0VUSHhsQjVqMUJ6TmJhQ0hJZ291bWJwVDdadlEyWjRESFhXNXJQdU55YVN6MEF2bTZ3dHljMDVxVTJVbXRWMXFOL1NOb203SkFkbms5ajg1TFUydUh1bVdqZHNLbVdFNXFLdWprcW16N3pEOE8yVGhuVzc3SWZQRngveG8yWXlaZUU2OXRFVlJCZ0dHUFA3R1NyaTVMaUl2TzZwTkZMak96QnRJTERnMUxFQUo1VUlqNjd4U0VzUjRIRW9CVEM1NHZKSUFoLzMxak0vUE96VFdkZ01YcXhlSlhGenE3ZHRqTWcrLzNSY0hIVm9LRGd3NjlrMmNnYXpwRzIwdVZuMTZkUUJYN0Jpdk12TVc3OStUb0xPcm0ydXhrN0VtUkNTVFUzMlFBbVZ6Z09mZHRKUDF2TWk0SU93aDVSYW51YWh2ekhIWDNHR3pRTkx4a1RiUXFFUjZmaHV0cEVVcEFOWmVMbnA2UzVaMkIwZGtVZ1BSeGc2NUpXY05OS1BSc0NrWkkxK1NTU1haeVMrOWFrUzhtd3c0NXRzdHRaTlZSR003RVh4YjUrU0FkMkwybFpLbnlNRll1M2lSZWcrSnZtMUIxVFZRL3lKejY0MFlLUzNMYitIQ3hoSmhTTGlhYk5Kb1Y3V1VuZTBGekt6bXVDdGRtR3BIWkM5cXh1SGdDcHRuVTJVb2oxNWF6Y2dBWHAyZjF6OHVUVCs2dXpxTm5lOVdzUHlwdE9NdlFhNDJzdmtZNlU5TkcvSi9VUTdRRmUrL2VUNnp6ZGQ2a2lSNzA1ZXBVeTA5MkpIekQrOWJDMmxtYWk4RGo0U3o0MVhUWmEvTHYzdXQwOTk1L3ptb3Z1R2M2VTdXMmFTSG9LcUs4dXRKeEliVmtKb3hYbFFuckVIMlM1YXc3WG1lREtwbzVwYXlLTU56eXhJeGxoTmxsRUVqbVdnODFBajRLbmFsOUR3N09sVVhrWE02eWFqWjNqaUN2Uk9RQVRVbGVid2gzWFppTnJtc1J4b3lWMVg5OWEzaWN1THlpRUJBVkRYQ0kwcTJqYWdCc1h4L3Nrci9oa2lhYkZqSCs3MUVvVWVjRm11RmhvcGxMak1td0tsSWFwTk02NENUaGRpdGdoUklUTFVDb2ZGYUQxOHd6bldlaEZrSFlVUW1JWXRFdzJYb005V2FMN1Fod0ZoeHVhL3FScFRLUTMxOXBNWk1qN2UrMlhaYk93Y0VLYnE2MFhRSllHaDNGMTUxSENYVW9lRHY1Nm42Yk52ckU2YVdkdkFEa3F1RWw3RTNYSmdueTlXOVJCSnMvMEo4QkxjNnlPOWk3V3ZIVVFwN3JRZkhGWklMbEIwSHgrcXhVWG1LWG5KZTczcGhSY0tTNjVIb3d4WjYreHppclFHTXhtci85R05VUEd5TVROR1ArRGlKbW1La3FMT09jd0NFSFNuZnl1NmExN24rM1l3U1g5NitNbkVmdTIwckhPb1pqVVZuT1ptRkNFRFFYZ0s3NWR5b3BzclZlM0pLZHBmYVFOVDllT0dvNk1qZDNiS3UrTVZDR3g4TitrNWE3ajJ3cGxtTVhuTVB6Q1JmcEkzd1pQeTRQc1VhVTEyS2xvaUZFT3poZTJGMk9EYUwxU3lGY2RueWhyRTgvcnZSc3pVV2R3UjZEai9LaVh0OC9vSm5aY2R5bDdjKzZSNi9HenRlSHZqY0oya1BnUk9nTURpUDFlTmVkNzJ0UmtzQjllNXZsczNZRnNrNWlBa1hiNEw5MjRibGx6c1VXQXBCeFRkMXFLS1hYZzhQVFhqanNCYTQ5dnNBNUVvaFZmbUUwMnR2NHNjQkZMaU5VcXdZY0dxZ25KbEFQQ3FaOGdnT1E3VnhWZFZuMVlRZkJPMm1vVGdaU0wrQ0NUeE9qRXlwSkR2RnM5Z01JZ0l3V0hBSFhNOGdQanZucXBsN1d3bXh0a2l3aUdEU1V6bjFscHViTEV2czdxYWxNZ2hieXVuVzRlcU5WdlBmUmlNMlllSGhheUF6dFF5MXFXaTJ6cGg1VWExWjFKQVREaW9FanBFMHNTN0FlZEdPNmovVzFmZDV6R1pibHlIN1M5VDY2ZEU0OHRjaFo0R0YxVmsvZlFYSGRRT0lkb1pabUE1T05yS0FuQWVWOEh2QUFxa29Nd3J4ZGFXN1lYMWNHSitXTUdOR08xU0VLVWczMldmUG1pY1hLTG9JWDBEUW1OYmd3TjNFenVtMm1uNjNNUTN2amVqckVaRnpsUmhKbStDYVMwTGJxVDVIRkRWWVBOb2xOZjl3aEFxMzAvSFZaaWt6UHhWM3lJQmlrdmE1QTFuSmZWcFhZRXFNa2IvaDR0V05iNXN3U2pucUVEOHViOU5Pc3RIcmVZaE9RYnBLelVHU2RjTkF1K1IrVDJzUlJBQzQ2TlZadmZOcVZ5UU90YVh0czhRdzRXdEJCaXpKS0xtLzBYd1oxMXdNZmtqci9nckg0cmllRGEwZTd1S2tQV3pZOXJqc2lpQ2MwL1ozUjBIM1hLNTVTOTB1bjVIVitRbkt1eHlyYk03UGhFUXVnRVE2TlQ3cVhvV1U1b3BSL1p2NmloSEhYMzB1ZStFYW53SmdlUUN4WGpsTlZEUGY2WEpFNXQ4eWxLMFd5andOQWZpdzdiZ2F3MVk5YVU1MU5ZZUc3QXo5RldXSWhHVGpmSXZMMThVY0pQWndib1hqNHZHMkdyc01YRDNMUjFKV2xJZGIwL29uTndvOENZYncrOFlhc3RtOUw0dzlDQUQzTHZRVzBBQ3dkS3hMQ1NycW5UWmw0YStTbHUvNXFTN2RIdzlBSGpMVnFFRGtLWU9qNnFEVHdpTWFzNkR4bEt0RmxXa2wyaWZFaFl0UGVERmg5d2ZOdHFhUjdBRFZtcWQyL3p0aFpzcmlaTVpvdmluMlJSWGptSTZsRGRkMFhScFhNV2hWQkUrN1JKZ0VRZTNzV28yS3d2TUtZMS9PcWVXYkxndUZZSVZTZ0w4NkFTU3g8L3hlbmM6Q2lwaGVyVmFsdWU%2BPC94ZW5jOkNpcGhlckRhdGE%2BPC94ZW5jOkVuY3J5cHRlZERhdGE%2BPC9zYW1sMjpFbmNyeXB0ZWRBc3NlcnRpb24%2BPC9zYW1sMnA6UmVzcG9uc2U%2B \ No newline at end of file diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index 25e060c099..4b431069fd 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -32,15 +32,8 @@ from third_party_auth.tests import testutil class IntegrationTest(testutil.TestCase, test.TestCase): """Abstract base class for provider integration tests.""" - # Configuration. You will need to override these values in your test cases. - - # Class. The third_party_auth.provider.BaseProvider child we are testing. - PROVIDER_CLASS = None - - # Dict of string -> object. Settings that will be merged onto Django's - # settings object before test execution. In most cases, this is - # PROVIDER_CLASS.SETTINGS with test values. - PROVIDER_SETTINGS = {} + # Override setUp and set this: + provider = None # Methods you must override in your children. @@ -94,10 +87,10 @@ class IntegrationTest(testutil.TestCase, test.TestCase): """ self.assertEqual(200, response.status_code) # Check that the correct provider was selected. - self.assertIn('successfully signed in with %s' % self.PROVIDER_CLASS.NAME, response.content) + self.assertIn('successfully signed in with %s' % self.provider.name, response.content) # Expect that each truthy value we've prepopulated the register form # with is actually present. - for prepopulated_form_value in self.PROVIDER_CLASS.get_register_form_data(pipeline_kwargs).values(): + for prepopulated_form_value in self.provider.get_register_form_data(pipeline_kwargs).values(): if prepopulated_form_value: self.assertIn(prepopulated_form_value, response.content) @@ -106,27 +99,30 @@ class IntegrationTest(testutil.TestCase, test.TestCase): def setUp(self): super(IntegrationTest, self).setUp() - self.configure_runtime() - self.backend_name = self.PROVIDER_CLASS.BACKEND_CLASS.name - self.client = test.Client() self.request_factory = test.RequestFactory() - def assert_account_settings_context_looks_correct(self, context, user, duplicate=False, linked=None): + @property + def backend_name(self): + """ Shortcut for the backend name """ + return self.provider.backend_name + + # pylint: disable=invalid-name + def assert_account_settings_context_looks_correct(self, context, _user, duplicate=False, linked=None): """Asserts the user's account settings page context is in the expected state. If duplicate is True, we expect context['duplicate_provider'] to contain - the duplicate provider object. If linked is passed, we conditionally + the duplicate provider backend name. If linked is passed, we conditionally check that the provider is included in context['auth']['providers'] and its connected state is correct. """ if duplicate: - self.assertEqual(context['duplicate_provider'].NAME, self.PROVIDER_CLASS.NAME) + self.assertEqual(context['duplicate_provider'], self.provider.backend_name) else: self.assertIsNone(context['duplicate_provider']) if linked is not None: expected_provider = [ - provider for provider in context['auth']['providers'] if provider['name'] == self.PROVIDER_CLASS.NAME + provider for provider in context['auth']['providers'] if provider['name'] == self.provider.name ][0] self.assertIsNotNone(expected_provider) self.assertEqual(expected_provider['connected'], linked) @@ -197,7 +193,10 @@ class IntegrationTest(testutil.TestCase, test.TestCase): def assert_json_failure_response_is_missing_social_auth(self, response): """Asserts failure on /login for missing social auth looks right.""" self.assertEqual(403, response.status_code) - self.assertIn("successfully logged into your %s account, but this account isn't linked" % self.PROVIDER_CLASS.NAME, response.content) + self.assertIn( + "successfully logged into your %s account, but this account isn't linked" % self.provider.name, + response.content + ) def assert_json_failure_response_is_username_collision(self, response): """Asserts the json response indicates a username collision.""" @@ -211,7 +210,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): self.assertEqual(200, response.status_code) payload = json.loads(response.content) self.assertTrue(payload.get('success')) - self.assertEqual(pipeline.get_complete_url(self.PROVIDER_CLASS.BACKEND_CLASS.name), payload.get('redirect_url')) + self.assertEqual(pipeline.get_complete_url(self.provider.backend_name), payload.get('redirect_url')) def assert_login_response_before_pipeline_looks_correct(self, response): """Asserts a GET of /login not in the pipeline looks correct.""" @@ -219,7 +218,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): # The combined login/registration page dynamically generates the login button, # but we can still check that the provider name is passed in the data attribute # for the container element. - self.assertIn(self.PROVIDER_CLASS.NAME, response.content) + self.assertIn(self.provider.name, response.content) def assert_login_response_in_pipeline_looks_correct(self, response): """Asserts a GET of /login in the pipeline looks correct.""" @@ -258,28 +257,21 @@ class IntegrationTest(testutil.TestCase, test.TestCase): # The combined login/registration page dynamically generates the register button, # but we can still check that the provider name is passed in the data attribute # for the container element. - self.assertIn(self.PROVIDER_CLASS.NAME, response.content) + self.assertIn(self.provider.name, response.content) def assert_social_auth_does_not_exist_for_user(self, user, strategy): """Asserts a user does not have an auth with the expected provider.""" social_auths = strategy.storage.user.get_social_auth_for_user( - user, provider=self.PROVIDER_CLASS.BACKEND_CLASS.name) + user, provider=self.provider.backend_name) self.assertEqual(0, len(social_auths)) def assert_social_auth_exists_for_user(self, user, strategy): """Asserts a user has a social auth with the expected provider.""" social_auths = strategy.storage.user.get_social_auth_for_user( - user, provider=self.PROVIDER_CLASS.BACKEND_CLASS.name) + user, provider=self.provider.backend_name) self.assertEqual(1, len(social_auths)) self.assertEqual(self.backend_name, social_auths[0].provider) - def configure_runtime(self): - """Configures settings details.""" - auth_settings.apply_settings({self.PROVIDER_CLASS.NAME: self.PROVIDER_SETTINGS}, django_settings) - # Force settings to propagate into cached members on - # social.apps.django_app.utils. - reload(social_utils) - def create_user_models_for_existing_account(self, strategy, email, password, username, skip_social_auth=False): """Creates user, profile, registration, and (usually) social auth. @@ -296,7 +288,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): registration.save() if not skip_social_auth: - social_utils.Storage.user.create_social_auth(user, uid, self.PROVIDER_CLASS.BACKEND_CLASS.name) + social_utils.Storage.user.create_social_auth(user, uid, self.provider.backend_name) return user @@ -370,7 +362,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): self.assertEqual(response.status_code, 302) self.assertEqual( response["Location"], - pipeline.get_complete_url(self.PROVIDER_CLASS.BACKEND_CLASS.name) + pipeline.get_complete_url(self.provider.backend_name) ) self.assertEqual(response.cookies[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME].value, 'true') self.assertIn(django_settings.EDXMKTG_USER_INFO_COOKIE_NAME, response.cookies) @@ -417,7 +409,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): # Instrument the pipeline to get to the dashboard with the full # expected state. self.client.get( - pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN)) + pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access mako_middleware_process_request(strategy.request) @@ -465,7 +457,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): # Instrument the pipeline to get to the dashboard with the full # expected state. self.client.get( - pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN)) + pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access mako_middleware_process_request(strategy.request) @@ -524,7 +516,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): self.assert_social_auth_exists_for_user(user, strategy) self.client.get('/login') - self.client.get(pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN)) + self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access mako_middleware_process_request(strategy.request) @@ -536,7 +528,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): request._messages = fallback.FallbackStorage(request) middleware.ExceptionMiddleware().process_exception( request, - exceptions.AuthAlreadyAssociated(self.PROVIDER_CLASS.BACKEND_CLASS.name, 'account is already in use.')) + exceptions.AuthAlreadyAssociated(self.provider.backend_name, 'account is already in use.')) self.assert_account_settings_context_looks_correct( account_settings_context(request), user, duplicate=True, linked=True) @@ -561,7 +553,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): # Synthesize that request and check that it redirects to the correct # provider page. self.assert_redirect_to_provider_looks_correct(self.client.get( - pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))) + pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))) # Next, the provider makes a request against /auth/complete/ # to resume the pipeline. @@ -641,7 +633,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): # Synthesize that request and check that it redirects to the correct # provider page. self.assert_redirect_to_provider_looks_correct(self.client.get( - pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))) + pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))) # Next, the provider makes a request against /auth/complete/. # pylint: disable=protected-access diff --git a/common/djangoapps/third_party_auth/tests/specs/test_google.py b/common/djangoapps/third_party_auth/tests/specs/test_google.py index 320739b81e..d591c1e594 100644 --- a/common/djangoapps/third_party_auth/tests/specs/test_google.py +++ b/common/djangoapps/third_party_auth/tests/specs/test_google.py @@ -7,11 +7,14 @@ from third_party_auth.tests.specs import base class GoogleOauth2IntegrationTest(base.Oauth2IntegrationTest): """Integration tests for provider.GoogleOauth2.""" - PROVIDER_CLASS = provider.GoogleOauth2 - PROVIDER_SETTINGS = { - 'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': 'google_oauth2_key', - 'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET': 'google_oauth2_secret', - } + def setUp(self): + super(GoogleOauth2IntegrationTest, self).setUp() + self.provider = self.configure_google_provider( + enabled=True, + key='google_oauth2_key', + secret='google_oauth2_secret', + ) + TOKEN_RESPONSE_DATA = { 'access_token': 'access_token_value', 'expires_in': 'expires_in_value', diff --git a/common/djangoapps/third_party_auth/tests/specs/test_linkedin.py b/common/djangoapps/third_party_auth/tests/specs/test_linkedin.py index e51cc2ecc7..c149065115 100644 --- a/common/djangoapps/third_party_auth/tests/specs/test_linkedin.py +++ b/common/djangoapps/third_party_auth/tests/specs/test_linkedin.py @@ -7,11 +7,14 @@ from third_party_auth.tests.specs import base class LinkedInOauth2IntegrationTest(base.Oauth2IntegrationTest): """Integration tests for provider.LinkedInOauth2.""" - PROVIDER_CLASS = provider.LinkedInOauth2 - PROVIDER_SETTINGS = { - 'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': 'linkedin_oauth2_key', - 'SOCIAL_AUTH_LINKEDIN_OAUTH2_SECRET': 'linkedin_oauth2_secret', - } + def setUp(self): + super(LinkedInOauth2IntegrationTest, self).setUp() + self.provider = self.configure_linkedin_provider( + enabled=True, + key='linkedin_oauth2_key', + secret='linkedin_oauth2_secret', + ) + TOKEN_RESPONSE_DATA = { 'access_token': 'access_token_value', 'expires_in': 'expires_in_value', diff --git a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py new file mode 100644 index 0000000000..aacb945aa6 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py @@ -0,0 +1,230 @@ +""" +Third_party_auth integration tests using a mock version of the TestShib provider +""" +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +import httpretty +from mock import patch +from student.tests.factories import UserFactory +from third_party_auth.tasks import fetch_saml_metadata +from third_party_auth.tests import testutil +import unittest + +TESTSHIB_ENTITY_ID = 'https://idp.testshib.org/idp/shibboleth' +TESTSHIB_METADATA_URL = 'https://mock.testshib.org/metadata/testshib-providers.xml' +TESTSHIB_SSO_URL = 'https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO' + +TPA_TESTSHIB_LOGIN_URL = '/auth/login/tpa-saml/?auth_entry=login&next=%2Fdashboard&idp=testshib' +TPA_TESTSHIB_REGISTER_URL = '/auth/login/tpa-saml/?auth_entry=register&next=%2Fdashboard&idp=testshib' +TPA_TESTSHIB_COMPLETE_URL = '/auth/complete/tpa-saml/' + + +@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled') +class TestShibIntegrationTest(testutil.SAMLTestCase): + """ + TestShib provider Integration Test, to test SAML functionality + """ + def setUp(self): + super(TestShibIntegrationTest, self).setUp() + self.login_page_url = reverse('signin_user') + self.register_page_url = reverse('register_user') + self.enable_saml( + private_key=self._get_private_key(), + public_key=self._get_public_key(), + entity_id="https://saml.example.none", + ) + # Mock out HTTP requests that may be made to TestShib: + httpretty.enable() + + def metadata_callback(_request, _uri, headers): + """ Return a cached copy of TestShib's metadata by reading it from disk """ + return (200, headers, self._read_data_file('testshib_metadata.xml')) + httpretty.register_uri(httpretty.GET, TESTSHIB_METADATA_URL, content_type='text/xml', body=metadata_callback) + self.addCleanup(httpretty.disable) + self.addCleanup(httpretty.reset) + + # Configure the SAML library to use the same request ID for every request. + # Doing this and freezing the time allows us to play back recorded request/response pairs + uid_patch = patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.generate_unique_id', return_value='TESTID') + uid_patch.start() + self.addCleanup(uid_patch.stop) + + def test_login_before_metadata_fetched(self): + self._configure_testshib_provider(fetch_metadata=False) + # The user goes to the login page, and sees a button to login with TestShib: + self._check_login_page() + # The user clicks on the TestShib button: + try_login_response = self.client.get(TPA_TESTSHIB_LOGIN_URL) + # The user should be redirected to back to the login page: + self.assertEqual(try_login_response.status_code, 302) + self.assertEqual(try_login_response['Location'], self.url_prefix + self.login_page_url) + # When loading the login page, the user will see an error message: + response = self.client.get(self.login_page_url) + self.assertEqual(response.status_code, 200) + self.assertIn('Authentication with TestShib is currently unavailable.', response.content) + + def test_register(self): + self._configure_testshib_provider() + self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded. + # The user goes to the register page, and sees a button to register with TestShib: + self._check_register_page() + # The user clicks on the TestShib button: + try_login_response = self.client.get(TPA_TESTSHIB_REGISTER_URL) + # The user should be redirected to TestShib: + self.assertEqual(try_login_response.status_code, 302) + self.assertTrue(try_login_response['Location'].startswith(TESTSHIB_SSO_URL)) + # Now the user will authenticate with the SAML provider + testshib_response = self._fake_testshib_login_and_return() + # We should be redirected to the register screen since this account is not linked to an edX account: + self.assertEqual(testshib_response.status_code, 302) + self.assertEqual(testshib_response['Location'], self.url_prefix + self.register_page_url) + register_response = self.client.get(self.register_page_url) + # We'd now like to see if the "You've successfully signed into TestShib" message is + # shown, but it's managed by a JavaScript runtime template, and we can't run JS in this + # type of test, so we just check for the variable that triggers that message. + self.assertIn('"currentProvider": "TestShib"', register_response.content) + self.assertIn('"errorMessage": null', register_response.content) + # Now do a crude check that the data (e.g. email) from the provider is displayed in the form: + self.assertIn('"defaultValue": "myself@testshib.org"', register_response.content) + self.assertIn('"defaultValue": "Me Myself And I"', register_response.content) + # Now complete the form: + ajax_register_response = self.client.post( + reverse('user_api_registration'), + { + 'email': 'myself@testshib.org', + 'name': 'Myself', + 'username': 'myself', + 'honor_code': True, + } + ) + self.assertEqual(ajax_register_response.status_code, 200) + # Then the AJAX will finish the third party auth: + continue_response = self.client.get(TPA_TESTSHIB_COMPLETE_URL) + # And we should be redirected to the dashboard: + self.assertEqual(continue_response.status_code, 302) + self.assertEqual(continue_response['Location'], self.url_prefix + reverse('dashboard')) + + # Now check that we can login again: + self.client.logout() + self._verify_user_email('myself@testshib.org') + self._test_return_login() + + def test_login(self): + self._configure_testshib_provider() + self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded. + user = UserFactory.create() + # The user goes to the login page, and sees a button to login with TestShib: + self._check_login_page() + # The user clicks on the TestShib button: + try_login_response = self.client.get(TPA_TESTSHIB_LOGIN_URL) + # The user should be redirected to TestShib: + self.assertEqual(try_login_response.status_code, 302) + self.assertTrue(try_login_response['Location'].startswith(TESTSHIB_SSO_URL)) + # Now the user will authenticate with the SAML provider + testshib_response = self._fake_testshib_login_and_return() + # We should be redirected to the login screen since this account is not linked to an edX account: + self.assertEqual(testshib_response.status_code, 302) + self.assertEqual(testshib_response['Location'], self.url_prefix + self.login_page_url) + login_response = self.client.get(self.login_page_url) + # We'd now like to see if the "You've successfully signed into TestShib" message is + # shown, but it's managed by a JavaScript runtime template, and we can't run JS in this + # type of test, so we just check for the variable that triggers that message. + self.assertIn('"currentProvider": "TestShib"', login_response.content) + self.assertIn('"errorMessage": null', login_response.content) + # Now the user enters their username and password. + # The AJAX on the page will log them in: + ajax_login_response = self.client.post( + reverse('user_api_login_session'), + {'email': user.email, 'password': 'test'} + ) + self.assertEqual(ajax_login_response.status_code, 200) + # Then the AJAX will finish the third party auth: + continue_response = self.client.get(TPA_TESTSHIB_COMPLETE_URL) + # And we should be redirected to the dashboard: + self.assertEqual(continue_response.status_code, 302) + self.assertEqual(continue_response['Location'], self.url_prefix + reverse('dashboard')) + + # Now check that we can login again: + self.client.logout() + self._test_return_login() + + def _test_return_login(self): + """ Test logging in to an account that is already linked. """ + # Make sure we're not logged in: + dashboard_response = self.client.get(reverse('dashboard')) + self.assertEqual(dashboard_response.status_code, 302) + # The user goes to the login page, and sees a button to login with TestShib: + self._check_login_page() + # The user clicks on the TestShib button: + try_login_response = self.client.get(TPA_TESTSHIB_LOGIN_URL) + # The user should be redirected to TestShib: + self.assertEqual(try_login_response.status_code, 302) + self.assertTrue(try_login_response['Location'].startswith(TESTSHIB_SSO_URL)) + # Now the user will authenticate with the SAML provider + login_response = self._fake_testshib_login_and_return() + # There will be one weird redirect required to set the login cookie: + self.assertEqual(login_response.status_code, 302) + self.assertEqual(login_response['Location'], self.url_prefix + TPA_TESTSHIB_COMPLETE_URL) + # And then we should be redirected to the dashboard: + login_response = self.client.get(TPA_TESTSHIB_COMPLETE_URL) + self.assertEqual(login_response.status_code, 302) + self.assertEqual(login_response['Location'], self.url_prefix + reverse('dashboard')) + # Now we are logged in: + dashboard_response = self.client.get(reverse('dashboard')) + self.assertEqual(dashboard_response.status_code, 200) + + def _freeze_time(self, timestamp): + """ Mock the current time for SAML, so we can replay canned requests/responses """ + now_patch = patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.now', return_value=timestamp) + now_patch.start() + self.addCleanup(now_patch.stop) + + def _check_login_page(self): + """ Load the login form and check that it contains a TestShib button """ + response = self.client.get(self.login_page_url) + self.assertEqual(response.status_code, 200) + self.assertIn("TestShib", response.content) + self.assertIn(TPA_TESTSHIB_LOGIN_URL.replace('&', '&'), response.content) + return response + + def _check_register_page(self): + """ Load the login form and check that it contains a TestShib button """ + response = self.client.get(self.register_page_url) + self.assertEqual(response.status_code, 200) + self.assertIn("TestShib", response.content) + self.assertIn(TPA_TESTSHIB_REGISTER_URL.replace('&', '&'), response.content) + return response + + def _configure_testshib_provider(self, **kwargs): + """ Enable and configure the TestShib SAML IdP as a third_party_auth provider """ + fetch_metadata = kwargs.pop('fetch_metadata', True) + kwargs.setdefault('name', 'TestShib') + kwargs.setdefault('enabled', True) + kwargs.setdefault('idp_slug', 'testshib') + kwargs.setdefault('entity_id', TESTSHIB_ENTITY_ID) + kwargs.setdefault('metadata_source', TESTSHIB_METADATA_URL) + kwargs.setdefault('icon_class', 'fa-university') + kwargs.setdefault('attr_email', 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6') # eduPersonPrincipalName + self.configure_saml_provider(**kwargs) + + if fetch_metadata: + self.assertTrue(httpretty.is_enabled()) + num_changed, num_failed, num_total = fetch_saml_metadata() + self.assertEqual(num_failed, 0) + self.assertEqual(num_changed, 1) + self.assertEqual(num_total, 1) + + def _fake_testshib_login_and_return(self): + """ Mocked: the user logs in to TestShib and then gets redirected back """ + # The SAML provider (TestShib) will authenticate the user, then get the browser to POST a response: + return self.client.post( + TPA_TESTSHIB_COMPLETE_URL, + content_type='application/x-www-form-urlencoded', + data=self._read_data_file('testshib_response.txt'), + ) + + def _verify_user_email(self, email): + """ Mark the user with the given email as verified """ + user = User.objects.get(email=email) + user.is_active = True + user.save() diff --git a/common/djangoapps/third_party_auth/tests/test_pipeline.py b/common/djangoapps/third_party_auth/tests/test_pipeline.py index 66c11d9043..c4387626ea 100644 --- a/common/djangoapps/third_party_auth/tests/test_pipeline.py +++ b/common/djangoapps/third_party_auth/tests/test_pipeline.py @@ -4,6 +4,7 @@ import random from third_party_auth import pipeline, provider from third_party_auth.tests import testutil +import unittest # Allow tests access to protected methods (or module-protected methods) under @@ -34,9 +35,11 @@ class MakeRandomPasswordTest(testutil.TestCase): self.assertEqual(expected, pipeline.make_random_password(choice_fn=random_instance.choice)) +@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled') class ProviderUserStateTestCase(testutil.TestCase): """Tests ProviderUserState behavior.""" def test_get_unlink_form_name(self): - state = pipeline.ProviderUserState(provider.GoogleOauth2, object(), False) - self.assertEqual(provider.GoogleOauth2.NAME + '_unlink_form', state.get_unlink_form_name()) + google_provider = self.configure_google_provider(enabled=True) + state = pipeline.ProviderUserState(google_provider, object(), 1000) + self.assertEqual(google_provider.provider_id + '_unlink_form', state.get_unlink_form_name()) diff --git a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py index 8d1f3b7019..d21d834c93 100644 --- a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py +++ b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py @@ -21,9 +21,7 @@ class TestCase(testutil.TestCase, test.TestCase): def setUp(self): super(TestCase, self).setUp() - self.enabled_provider_name = provider.GoogleOauth2.NAME - provider.Registry.configure_once([self.enabled_provider_name]) - self.enabled_provider = provider.Registry.get(self.enabled_provider_name) + self.enabled_provider = self.configure_google_provider(enabled=True) @unittest.skipUnless( @@ -41,28 +39,28 @@ class GetAuthenticatedUserTestCase(TestCase): def test_raises_does_not_exist_if_user_missing(self): with self.assertRaises(models.User.DoesNotExist): - pipeline.get_authenticated_user('new_' + self.user.username, 'backend') + pipeline.get_authenticated_user(self.enabled_provider, 'new_' + self.user.username, 'user@example.com') def test_raises_does_not_exist_if_user_found_but_no_association(self): backend_name = 'backend' self.assertIsNotNone(self.get_by_username(self.user.username)) - self.assertIsNone(provider.Registry.get_by_backend_name(backend_name)) + self.assertFalse(any(provider.Registry.get_enabled_by_backend_name(backend_name))) with self.assertRaises(models.User.DoesNotExist): - pipeline.get_authenticated_user(self.user.username, 'backend') + pipeline.get_authenticated_user(self.enabled_provider, self.user.username, 'user@example.com') def test_raises_does_not_exist_if_user_and_association_found_but_no_match(self): self.assertIsNotNone(self.get_by_username(self.user.username)) social_models.DjangoStorage.user.create_social_auth( - self.user, 'uid', 'other_' + self.enabled_provider.BACKEND_CLASS.name) + self.user, 'uid', 'other_' + self.enabled_provider.backend_name) with self.assertRaises(models.User.DoesNotExist): - pipeline.get_authenticated_user(self.user.username, self.enabled_provider.BACKEND_CLASS.name) + pipeline.get_authenticated_user(self.enabled_provider, self.user.username, 'uid') def test_returns_user_with_is_authenticated_and_backend_set_if_match(self): - social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', self.enabled_provider.BACKEND_CLASS.name) - user = pipeline.get_authenticated_user(self.user.username, self.enabled_provider.BACKEND_CLASS.name) + social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', self.enabled_provider.backend_name) + user = pipeline.get_authenticated_user(self.enabled_provider, self.user.username, 'uid') self.assertEqual(self.user, user) self.assertEqual(self.enabled_provider.get_authentication_backend(), user.backend) @@ -78,55 +76,70 @@ class GetProviderUserStatesTestCase(testutil.TestCase, test.TestCase): self.user = social_models.DjangoStorage.user.create_user(username='username', password='password') def test_returns_empty_list_if_no_enabled_providers(self): - provider.Registry.configure_once([]) + self.assertFalse(provider.Registry.enabled()) self.assertEquals([], pipeline.get_provider_user_states(self.user)) def test_state_not_returned_for_disabled_provider(self): - disabled_provider = provider.GoogleOauth2 - enabled_provider = provider.LinkedInOauth2 - provider.Registry.configure_once([enabled_provider.NAME]) - social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', disabled_provider.BACKEND_CLASS.name) + disabled_provider = self.configure_google_provider(enabled=False) + enabled_provider = self.configure_facebook_provider(enabled=True) + social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', disabled_provider.backend_name) states = pipeline.get_provider_user_states(self.user) self.assertEqual(1, len(states)) - self.assertNotIn(disabled_provider, (state.provider for state in states)) + self.assertNotIn(disabled_provider.provider_id, (state.provider.provider_id for state in states)) + self.assertIn(enabled_provider.provider_id, (state.provider.provider_id for state in states)) def test_states_for_enabled_providers_user_has_accounts_associated_with(self): - provider.Registry.configure_once([provider.GoogleOauth2.NAME, provider.LinkedInOauth2.NAME]) - social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', provider.GoogleOauth2.BACKEND_CLASS.name) - social_models.DjangoStorage.user.create_social_auth( - self.user, 'uid', provider.LinkedInOauth2.BACKEND_CLASS.name) + # Enable two providers - Google and LinkedIn: + google_provider = self.configure_google_provider(enabled=True) + linkedin_provider = self.configure_linkedin_provider(enabled=True) + user_social_auth_google = social_models.DjangoStorage.user.create_social_auth( + self.user, 'uid', google_provider.backend_name) + user_social_auth_linkedin = social_models.DjangoStorage.user.create_social_auth( + self.user, 'uid', linkedin_provider.backend_name) states = pipeline.get_provider_user_states(self.user) self.assertEqual(2, len(states)) - google_state = [state for state in states if state.provider == provider.GoogleOauth2][0] - linkedin_state = [state for state in states if state.provider == provider.LinkedInOauth2][0] + google_state = [state for state in states if state.provider.provider_id == google_provider.provider_id][0] + linkedin_state = [state for state in states if state.provider.provider_id == linkedin_provider.provider_id][0] self.assertTrue(google_state.has_account) - self.assertEqual(provider.GoogleOauth2, google_state.provider) + self.assertEqual(google_provider.provider_id, google_state.provider.provider_id) + # Also check the row ID. Note this 'id' changes whenever the configuration does: + self.assertEqual(google_provider.id, google_state.provider.id) # pylint: disable=no-member self.assertEqual(self.user, google_state.user) + self.assertEqual(user_social_auth_google.id, google_state.association_id) self.assertTrue(linkedin_state.has_account) - self.assertEqual(provider.LinkedInOauth2, linkedin_state.provider) + self.assertEqual(linkedin_provider.provider_id, linkedin_state.provider.provider_id) + self.assertEqual(linkedin_provider.id, linkedin_state.provider.id) # pylint: disable=no-member self.assertEqual(self.user, linkedin_state.user) + self.assertEqual(user_social_auth_linkedin.id, linkedin_state.association_id) def test_states_for_enabled_providers_user_has_no_account_associated_with(self): - provider.Registry.configure_once([provider.GoogleOauth2.NAME, provider.LinkedInOauth2.NAME]) + # Enable two providers - Google and LinkedIn: + google_provider = self.configure_google_provider(enabled=True) + linkedin_provider = self.configure_linkedin_provider(enabled=True) + self.assertEqual(len(provider.Registry.enabled()), 2) + states = pipeline.get_provider_user_states(self.user) self.assertEqual([], [x for x in social_models.DjangoStorage.user.objects.all()]) self.assertEqual(2, len(states)) - google_state = [state for state in states if state.provider == provider.GoogleOauth2][0] - linkedin_state = [state for state in states if state.provider == provider.LinkedInOauth2][0] + google_state = [state for state in states if state.provider.provider_id == google_provider.provider_id][0] + linkedin_state = [state for state in states if state.provider.provider_id == linkedin_provider.provider_id][0] self.assertFalse(google_state.has_account) - self.assertEqual(provider.GoogleOauth2, google_state.provider) + self.assertEqual(google_provider.provider_id, google_state.provider.provider_id) + # Also check the row ID. Note this 'id' changes whenever the configuration does: + self.assertEqual(google_provider.id, google_state.provider.id) # pylint: disable=no-member self.assertEqual(self.user, google_state.user) self.assertFalse(linkedin_state.has_account) - self.assertEqual(provider.LinkedInOauth2, linkedin_state.provider) + self.assertEqual(linkedin_provider.provider_id, linkedin_state.provider.provider_id) + self.assertEqual(linkedin_provider.id, linkedin_state.provider.id) # pylint: disable=no-member self.assertEqual(self.user, linkedin_state.user) @@ -136,7 +149,7 @@ class UrlFormationTestCase(TestCase): """Tests formation of URLs for pipeline hook points.""" def test_complete_url_raises_value_error_if_provider_not_enabled(self): - provider_name = 'not_enabled' + provider_name = 'oa2-not-enabled' self.assertIsNone(provider.Registry.get(provider_name)) @@ -144,36 +157,54 @@ class UrlFormationTestCase(TestCase): pipeline.get_complete_url(provider_name) def test_complete_url_returns_expected_format(self): - complete_url = pipeline.get_complete_url(self.enabled_provider.BACKEND_CLASS.name) + complete_url = pipeline.get_complete_url(self.enabled_provider.backend_name) self.assertTrue(complete_url.startswith('/auth/complete')) - self.assertIn(self.enabled_provider.BACKEND_CLASS.name, complete_url) + self.assertIn(self.enabled_provider.backend_name, complete_url) def test_disconnect_url_raises_value_error_if_provider_not_enabled(self): - provider_name = 'not_enabled' + provider_name = 'oa2-not-enabled' self.assertIsNone(provider.Registry.get(provider_name)) with self.assertRaises(ValueError): - pipeline.get_disconnect_url(provider_name) + pipeline.get_disconnect_url(provider_name, 1000) def test_disconnect_url_returns_expected_format(self): - disconnect_url = pipeline.get_disconnect_url(self.enabled_provider.NAME) - - self.assertTrue(disconnect_url.startswith('/auth/disconnect')) - self.assertIn(self.enabled_provider.BACKEND_CLASS.name, disconnect_url) + disconnect_url = pipeline.get_disconnect_url(self.enabled_provider.provider_id, 1000) + disconnect_url = disconnect_url.rstrip('?') + self.assertEqual( + disconnect_url, + '/auth/disconnect/{backend}/{association_id}/'.format( + backend=self.enabled_provider.backend_name, association_id=1000) + ) def test_login_url_raises_value_error_if_provider_not_enabled(self): - provider_name = 'not_enabled' + provider_id = 'oa2-not-enabled' - self.assertIsNone(provider.Registry.get(provider_name)) + self.assertIsNone(provider.Registry.get(provider_id)) with self.assertRaises(ValueError): - pipeline.get_login_url(provider_name, pipeline.AUTH_ENTRY_LOGIN) + pipeline.get_login_url(provider_id, pipeline.AUTH_ENTRY_LOGIN) def test_login_url_returns_expected_format(self): - login_url = pipeline.get_login_url(self.enabled_provider.NAME, pipeline.AUTH_ENTRY_LOGIN) + login_url = pipeline.get_login_url(self.enabled_provider.provider_id, pipeline.AUTH_ENTRY_LOGIN) self.assertTrue(login_url.startswith('/auth/login')) - self.assertIn(self.enabled_provider.BACKEND_CLASS.name, login_url) + self.assertIn(self.enabled_provider.backend_name, login_url) self.assertTrue(login_url.endswith(pipeline.AUTH_ENTRY_LOGIN)) + + def test_for_value_error_if_provider_id_invalid(self): + provider_id = 'invalid' # Format is normally "{prefix}-{identifier}" + + with self.assertRaises(ValueError): + provider.Registry.get(provider_id) + + with self.assertRaises(ValueError): + pipeline.get_login_url(provider_id, pipeline.AUTH_ENTRY_LOGIN) + + with self.assertRaises(ValueError): + pipeline.get_disconnect_url(provider_id, 1000) + + with self.assertRaises(ValueError): + pipeline.get_complete_url(provider_id) diff --git a/common/djangoapps/third_party_auth/tests/test_provider.py b/common/djangoapps/third_party_auth/tests/test_provider.py index 20120d7329..bc3f71660a 100644 --- a/common/djangoapps/third_party_auth/tests/test_provider.py +++ b/common/djangoapps/third_party_auth/tests/test_provider.py @@ -1,82 +1,84 @@ """Unit tests for provider.py.""" +from mock import Mock, patch from third_party_auth import provider from third_party_auth.tests import testutil +import unittest +@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled') class RegistryTest(testutil.TestCase): """Tests registry discovery and operation.""" - # Allow access to protected methods (or module-protected methods) under - # test. pylint: disable-msg=protected-access - - def test_calling_configure_once_twice_raises_value_error(self): - provider.Registry.configure_once([provider.GoogleOauth2.NAME]) - - with self.assertRaisesRegexp(ValueError, '^.*already configured$'): - provider.Registry.configure_once([provider.GoogleOauth2.NAME]) - def test_configure_once_adds_gettable_providers(self): - provider.Registry.configure_once([provider.GoogleOauth2.NAME]) - self.assertIs(provider.GoogleOauth2, provider.Registry.get(provider.GoogleOauth2.NAME)) + facebook_provider = self.configure_facebook_provider(enabled=True) + # pylint: disable=no-member + self.assertEqual(facebook_provider.id, provider.Registry.get(facebook_provider.provider_id).id) - def test_configuring_provider_with_no_implementation_raises_value_error(self): - with self.assertRaisesRegexp(ValueError, '^.*no_implementation$'): - provider.Registry.configure_once(['no_implementation']) + def test_no_providers_by_default(self): + enabled_providers = provider.Registry.enabled() + self.assertEqual(len(enabled_providers), 0, "By default, no providers are enabled.") - def test_configuring_single_provider_twice_raises_value_error(self): - provider.Registry._enable(provider.GoogleOauth2) + def test_runtime_configuration(self): + self.configure_google_provider(enabled=True) + enabled_providers = provider.Registry.enabled() + self.assertEqual(len(enabled_providers), 1) + self.assertEqual(enabled_providers[0].name, "Google") + self.assertEqual(enabled_providers[0].secret, "opensesame") - with self.assertRaisesRegexp(ValueError, '^.*already enabled'): - provider.Registry.configure_once([provider.GoogleOauth2.NAME]) + self.configure_google_provider(enabled=False) + enabled_providers = provider.Registry.enabled() + self.assertEqual(len(enabled_providers), 0) - def test_custom_provider_can_be_enabled(self): - name = 'CustomProvider' + self.configure_google_provider(enabled=True, secret="alohomora") + enabled_providers = provider.Registry.enabled() + self.assertEqual(len(enabled_providers), 1) + self.assertEqual(enabled_providers[0].secret, "alohomora") - with self.assertRaisesRegexp(ValueError, '^No implementation.*$'): - provider.Registry.configure_once([name]) - - class CustomProvider(provider.BaseProvider): - """Custom class to ensure BaseProvider children outside provider can be enabled.""" - - NAME = name - - provider.Registry._reset() - provider.Registry.configure_once([CustomProvider.NAME]) - self.assertEqual([CustomProvider], provider.Registry.enabled()) - - def test_enabled_raises_runtime_error_if_not_configured(self): - with self.assertRaisesRegexp(RuntimeError, '^.*not configured$'): - provider.Registry.enabled() + def test_cannot_load_arbitrary_backends(self): + """ Test that only backend_names listed in settings.AUTHENTICATION_BACKENDS can be used """ + self.configure_oauth_provider(enabled=True, name="Disallowed", backend_name="disallowed") + self.enable_saml() + self.configure_saml_provider(enabled=True, name="Disallowed", idp_slug="test", backend_name="disallowed") + self.assertEqual(len(provider.Registry.enabled()), 0) def test_enabled_returns_list_of_enabled_providers_sorted_by_name(self): - all_providers = provider.Registry._get_all() - provider.Registry.configure_once(all_providers.keys()) - self.assertEqual( - sorted(all_providers.values(), key=lambda provider: provider.NAME), provider.Registry.enabled()) + provider_names = ["Stack Overflow", "Google", "LinkedIn", "GitHub"] + backend_names = [] + for name in provider_names: + backend_name = name.lower().replace(' ', '') + backend_names.append(backend_name) + self.configure_oauth_provider(enabled=True, name=name, backend_name=backend_name) - def test_get_raises_runtime_error_if_not_configured(self): - with self.assertRaisesRegexp(RuntimeError, '^.*not configured$'): - provider.Registry.get('anything') + with patch('third_party_auth.provider._PSA_OAUTH2_BACKENDS', backend_names): + self.assertEqual(sorted(provider_names), [prov.name for prov in provider.Registry.enabled()]) def test_get_returns_enabled_provider(self): - provider.Registry.configure_once([provider.GoogleOauth2.NAME]) - self.assertIs(provider.GoogleOauth2, provider.Registry.get(provider.GoogleOauth2.NAME)) + google_provider = self.configure_google_provider(enabled=True) + # pylint: disable=no-member + self.assertEqual(google_provider.id, provider.Registry.get(google_provider.provider_id).id) def test_get_returns_none_if_provider_not_enabled(self): - provider.Registry.configure_once([]) - self.assertIsNone(provider.Registry.get(provider.LinkedInOauth2.NAME)) + linkedin_provider_id = "oa2-linkedin-oauth2" + # At this point there should be no configuration entries at all so no providers should be enabled + self.assertEqual(provider.Registry.enabled(), []) + self.assertIsNone(provider.Registry.get(linkedin_provider_id)) + # Now explicitly disabled this provider: + self.configure_linkedin_provider(enabled=False) + self.assertIsNone(provider.Registry.get(linkedin_provider_id)) + self.configure_linkedin_provider(enabled=True) + self.assertEqual(provider.Registry.get(linkedin_provider_id).provider_id, linkedin_provider_id) - def test_get_by_backend_name_raises_runtime_error_if_not_configured(self): - with self.assertRaisesRegexp(RuntimeError, '^.*not configured$'): - provider.Registry.get_by_backend_name('') + def test_get_from_pipeline_returns_none_if_provider_not_enabled(self): + self.assertEqual(provider.Registry.enabled(), [], "By default, no providers are enabled.") + self.assertIsNone(provider.Registry.get_from_pipeline(Mock())) - def test_get_by_backend_name_returns_enabled_provider(self): - provider.Registry.configure_once([provider.GoogleOauth2.NAME]) - self.assertIs( - provider.GoogleOauth2, - provider.Registry.get_by_backend_name(provider.GoogleOauth2.BACKEND_CLASS.name)) + def test_get_enabled_by_backend_name_returns_enabled_provider(self): + google_provider = self.configure_google_provider(enabled=True) + found = list(provider.Registry.get_enabled_by_backend_name(google_provider.backend_name)) + self.assertEqual(found, [google_provider]) - def test_get_by_backend_name_returns_none_if_provider_not_enabled(self): - provider.Registry.configure_once([]) - self.assertIsNone(provider.Registry.get_by_backend_name(provider.GoogleOauth2.BACKEND_CLASS.name)) + def test_get_enabled_by_backend_name_returns_none_if_provider_not_enabled(self): + google_provider = self.configure_google_provider(enabled=False) + found = list(provider.Registry.get_enabled_by_backend_name(google_provider.backend_name)) + self.assertEqual(found, []) diff --git a/common/djangoapps/third_party_auth/tests/test_settings.py b/common/djangoapps/third_party_auth/tests/test_settings.py index 40babdbc1c..1c1229190e 100644 --- a/common/djangoapps/third_party_auth/tests/test_settings.py +++ b/common/djangoapps/third_party_auth/tests/test_settings.py @@ -2,6 +2,7 @@ from third_party_auth import provider, settings from third_party_auth.tests import testutil +import unittest _ORIGINAL_AUTHENTICATION_BACKENDS = ('first_authentication_backend',) @@ -30,56 +31,26 @@ class SettingsUnitTest(testutil.TestCase): self.settings = testutil.FakeDjangoSettings(_SETTINGS_MAP) def test_apply_settings_adds_exception_middleware(self): - settings.apply_settings({}, self.settings) + settings.apply_settings(self.settings) for middleware_name in settings._MIDDLEWARE_CLASSES: self.assertIn(middleware_name, self.settings.MIDDLEWARE_CLASSES) def test_apply_settings_adds_fields_stored_in_session(self): - settings.apply_settings({}, self.settings) + settings.apply_settings(self.settings) self.assertEqual(settings._FIELDS_STORED_IN_SESSION, self.settings.FIELDS_STORED_IN_SESSION) def test_apply_settings_adds_third_party_auth_to_installed_apps(self): - settings.apply_settings({}, self.settings) + settings.apply_settings(self.settings) self.assertIn('third_party_auth', self.settings.INSTALLED_APPS) - def test_apply_settings_enables_no_providers_and_completes_when_app_info_empty(self): - settings.apply_settings({}, self.settings) + @unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled') + def test_apply_settings_enables_no_providers_by_default(self): + # Providers are only enabled via ConfigurationModels in the database + settings.apply_settings(self.settings) self.assertEqual([], provider.Registry.enabled()) - def test_apply_settings_initializes_stubs_and_merges_settings_from_auth_info(self): - for key in provider.GoogleOauth2.SETTINGS: - self.assertFalse(hasattr(self.settings, key)) - - auth_info = { - provider.GoogleOauth2.NAME: { - 'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': 'google_oauth2_key', - }, - } - settings.apply_settings(auth_info, self.settings) - self.assertEqual('google_oauth2_key', self.settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY) - self.assertIsNone(self.settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET) - - def test_apply_settings_prepends_auth_backends(self): - self.assertEqual(_ORIGINAL_AUTHENTICATION_BACKENDS, self.settings.AUTHENTICATION_BACKENDS) - settings.apply_settings({provider.GoogleOauth2.NAME: {}, provider.LinkedInOauth2.NAME: {}}, self.settings) - self.assertEqual(( - provider.GoogleOauth2.get_authentication_backend(), provider.LinkedInOauth2.get_authentication_backend()) + - _ORIGINAL_AUTHENTICATION_BACKENDS, - self.settings.AUTHENTICATION_BACKENDS) - - def test_apply_settings_raises_value_error_if_provider_contains_uninitialized_setting(self): - bad_setting_name = 'bad_setting' - self.assertNotIn('bad_setting_name', provider.GoogleOauth2.SETTINGS) - auth_info = { - provider.GoogleOauth2.NAME: { - bad_setting_name: None, - }, - } - with self.assertRaisesRegexp(ValueError, '^.*not initialized$'): - settings.apply_settings(auth_info, self.settings) - def test_apply_settings_turns_off_raising_social_exceptions(self): # Guard against submitting a conf change that's convenient in dev but # bad in prod. - settings.apply_settings({}, self.settings) + settings.apply_settings(self.settings) self.assertFalse(self.settings.SOCIAL_AUTH_RAISE_EXCEPTIONS) diff --git a/common/djangoapps/third_party_auth/tests/test_settings_integration.py b/common/djangoapps/third_party_auth/tests/test_settings_integration.py deleted file mode 100644 index 8992f9fb79..0000000000 --- a/common/djangoapps/third_party_auth/tests/test_settings_integration.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Integration tests for settings.py.""" - -from django.conf import settings - -from third_party_auth import provider -from third_party_auth import settings as auth_settings -from third_party_auth.tests import testutil - - -class SettingsIntegrationTest(testutil.TestCase): - """Integration tests of auth settings pipeline. - - Note that ENABLE_THIRD_PARTY_AUTH is True in lms/envs/test.py and False in - cms/envs/test.py. This implicitly gives us coverage of the full settings - mechanism with both values, so we do not have explicit test methods as they - are superfluous. - """ - - def test_can_enable_google_oauth2(self): - auth_settings.apply_settings({'Google': {'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': 'google_key'}}, settings) - self.assertEqual([provider.GoogleOauth2], provider.Registry.enabled()) - self.assertEqual('google_key', settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY) - - def test_can_enable_linkedin_oauth2(self): - auth_settings.apply_settings({'LinkedIn': {'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': 'linkedin_key'}}, settings) - self.assertEqual([provider.LinkedInOauth2], provider.Registry.enabled()) - self.assertEqual('linkedin_key', settings.SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY) diff --git a/common/djangoapps/third_party_auth/tests/test_views.py b/common/djangoapps/third_party_auth/tests/test_views.py new file mode 100644 index 0000000000..8e88629801 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/test_views.py @@ -0,0 +1,64 @@ +""" +Test the views served by third_party_auth. +""" +# pylint: disable=no-member +import ddt +from lxml import etree +import unittest +from .testutil import AUTH_FEATURE_ENABLED, SAMLTestCase + +# Define some XML namespaces: +from third_party_auth.tasks import SAML_XML_NS +XMLDSIG_XML_NS = 'http://www.w3.org/2000/09/xmldsig#' + + +@unittest.skipUnless(AUTH_FEATURE_ENABLED, 'third_party_auth not enabled') +@ddt.ddt +class SAMLMetadataTest(SAMLTestCase): + """ + Test the SAML metadata view + """ + METADATA_URL = '/auth/saml/metadata.xml' + + def test_saml_disabled(self): + """ When SAML is not enabled, the metadata view should return 404 """ + self.enable_saml(enabled=False) + response = self.client.get(self.METADATA_URL) + self.assertEqual(response.status_code, 404) + + @ddt.data('saml_key', 'saml_key_alt') # Test two slightly different key pair export formats + def test_metadata(self, key_name): + self.enable_saml( + private_key=self._get_private_key(key_name), + public_key=self._get_public_key(key_name), + entity_id="https://saml.example.none", + ) + doc = self._fetch_metadata() + # Check the ACS URL: + acs_node = doc.find(".//{}".format(etree.QName(SAML_XML_NS, 'AssertionConsumerService'))) + self.assertIsNotNone(acs_node) + self.assertEqual(acs_node.attrib['Location'], 'http://example.none/auth/complete/tpa-saml/') + + def test_signed_metadata(self): + self.enable_saml( + private_key=self._get_private_key(), + public_key=self._get_public_key(), + entity_id="https://saml.example.none", + other_config_str='{"SECURITY_CONFIG": {"signMetadata": true} }', + ) + doc = self._fetch_metadata() + sig_node = doc.find(".//{}".format(etree.QName(XMLDSIG_XML_NS, 'SignatureValue'))) + self.assertIsNotNone(sig_node) + + def _fetch_metadata(self): + """ Fetch and parse the metadata XML at self.METADATA_URL """ + response = self.client.get(self.METADATA_URL) + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'], 'text/xml') + # The result should be valid XML: + try: + metadata_doc = etree.fromstring(response.content) + except etree.LxmlError: + self.fail('SAML metadata must be valid XML') + self.assertEqual(metadata_doc.tag, etree.QName(SAML_XML_NS, 'EntityDescriptor')) + return metadata_doc diff --git a/common/djangoapps/third_party_auth/tests/testutil.py b/common/djangoapps/third_party_auth/tests/testutil.py index eb3f84e5e6..5d1a1f38c2 100644 --- a/common/djangoapps/third_party_auth/tests/testutil.py +++ b/common/djangoapps/third_party_auth/tests/testutil.py @@ -5,13 +5,16 @@ Used by Django and non-Django tests; must not have Django deps. """ from contextlib import contextmanager -import unittest +from django.conf import settings +import django.test import mock +import os.path -from third_party_auth import provider +from third_party_auth.models import OAuth2ProviderConfig, SAMLProviderConfig, SAMLConfiguration, cache as config_cache AUTH_FEATURES_KEY = 'ENABLE_THIRD_PARTY_AUTH' +AUTH_FEATURE_ENABLED = AUTH_FEATURES_KEY in settings.FEATURES class FakeDjangoSettings(object): @@ -23,22 +26,93 @@ class FakeDjangoSettings(object): setattr(self, key, value) -class TestCase(unittest.TestCase): - """Base class for auth test cases.""" - - # Allow access to protected methods (or module-protected methods) under - # test. - # pylint: disable-msg=protected-access - - def setUp(self): - super(TestCase, self).setUp() - self._original_providers = provider.Registry._get_all() - provider.Registry._reset() +class ThirdPartyAuthTestMixin(object): + """ Helper methods useful for testing third party auth functionality """ def tearDown(self): - provider.Registry._reset() - provider.Registry.configure_once(self._original_providers) - super(TestCase, self).tearDown() + config_cache.clear() + super(ThirdPartyAuthTestMixin, self).tearDown() + + def enable_saml(self, **kwargs): + """ Enable SAML support (via SAMLConfiguration, not for any particular provider) """ + kwargs.setdefault('enabled', True) + SAMLConfiguration(**kwargs).save() + + @staticmethod + def configure_oauth_provider(**kwargs): + """ Update the settings for an OAuth2-based third party auth provider """ + obj = OAuth2ProviderConfig(**kwargs) + obj.save() + return obj + + def configure_saml_provider(self, **kwargs): + """ Update the settings for a SAML-based third party auth provider """ + self.assertTrue(SAMLConfiguration.is_enabled(), "SAML Provider Configuration only works if SAML is enabled.") + obj = SAMLProviderConfig(**kwargs) + obj.save() + return obj + + @classmethod + def configure_google_provider(cls, **kwargs): + """ Update the settings for the Google third party auth provider/backend """ + kwargs.setdefault("name", "Google") + kwargs.setdefault("backend_name", "google-oauth2") + kwargs.setdefault("icon_class", "fa-google-plus") + kwargs.setdefault("key", "test-fake-key.apps.googleusercontent.com") + kwargs.setdefault("secret", "opensesame") + return cls.configure_oauth_provider(**kwargs) + + @classmethod + def configure_facebook_provider(cls, **kwargs): + """ Update the settings for the Facebook third party auth provider/backend """ + kwargs.setdefault("name", "Facebook") + kwargs.setdefault("backend_name", "facebook") + kwargs.setdefault("icon_class", "fa-facebook") + kwargs.setdefault("key", "FB_TEST_APP") + kwargs.setdefault("secret", "opensesame") + return cls.configure_oauth_provider(**kwargs) + + @classmethod + def configure_linkedin_provider(cls, **kwargs): + """ Update the settings for the LinkedIn third party auth provider/backend """ + kwargs.setdefault("name", "LinkedIn") + kwargs.setdefault("backend_name", "linkedin-oauth2") + kwargs.setdefault("icon_class", "fa-linkedin") + kwargs.setdefault("key", "test") + kwargs.setdefault("secret", "test") + return cls.configure_oauth_provider(**kwargs) + + +class TestCase(ThirdPartyAuthTestMixin, django.test.TestCase): + """Base class for auth test cases.""" + pass + + +class SAMLTestCase(TestCase): + """ + Base class for SAML-related third_party_auth tests + """ + + def setUp(self): + super(SAMLTestCase, self).setUp() + self.client.defaults['SERVER_NAME'] = 'example.none' # The SAML lib we use doesn't like testserver' as a domain + self.url_prefix = 'http://example.none' + + @classmethod + def _get_public_key(cls, key_name='saml_key'): + """ Get a public key for use in the test. """ + return cls._read_data_file('{}.pub'.format(key_name)) + + @classmethod + def _get_private_key(cls, key_name='saml_key'): + """ Get a private key for use in the test. """ + return cls._read_data_file('{}.key'.format(key_name)) + + @staticmethod + def _read_data_file(filename): + """ Read the contents of a file in the data folder """ + with open(os.path.join(os.path.dirname(__file__), 'data', filename)) as f: + return f.read() @contextmanager diff --git a/common/djangoapps/third_party_auth/tests/utils.py b/common/djangoapps/third_party_auth/tests/utils.py index 208930cdf4..cce2edd59b 100644 --- a/common/djangoapps/third_party_auth/tests/utils.py +++ b/common/djangoapps/third_party_auth/tests/utils.py @@ -9,9 +9,11 @@ from social.apps.django_app.default.models import UserSocialAuth from student.tests.factories import UserFactory +from .testutil import ThirdPartyAuthTestMixin + @httpretty.activate -class ThirdPartyOAuthTestMixin(object): +class ThirdPartyOAuthTestMixin(ThirdPartyAuthTestMixin): """ Mixin with tests for third party oauth views. A TestCase that includes this must define the following: @@ -32,6 +34,10 @@ class ThirdPartyOAuthTestMixin(object): if create_user: self.user = UserFactory() UserSocialAuth.objects.create(user=self.user, provider=self.BACKEND, uid=self.social_uid) + if self.BACKEND == 'google-oauth2': + self.configure_google_provider(enabled=True) + elif self.BACKEND == 'facebook': + self.configure_facebook_provider(enabled=True) def _setup_provider_response(self, success=False, email=''): """ diff --git a/common/djangoapps/third_party_auth/urls.py b/common/djangoapps/third_party_auth/urls.py index b020e775b5..5d366b2da3 100644 --- a/common/djangoapps/third_party_auth/urls.py +++ b/common/djangoapps/third_party_auth/urls.py @@ -2,10 +2,11 @@ from django.conf.urls import include, patterns, url -from .views import inactive_user_view +from .views import inactive_user_view, saml_metadata_view urlpatterns = patterns( '', url(r'^auth/inactive', inactive_user_view), + url(r'^auth/saml/metadata.xml', saml_metadata_view), url(r'^auth/', include('social.apps.django_app.urls', namespace='social')), ) diff --git a/common/djangoapps/third_party_auth/views.py b/common/djangoapps/third_party_auth/views.py index 5ae69db526..ef0233f33c 100644 --- a/common/djangoapps/third_party_auth/views.py +++ b/common/djangoapps/third_party_auth/views.py @@ -1,7 +1,12 @@ """ Extra views required for SSO """ +from django.conf import settings +from django.core.urlresolvers import reverse +from django.http import HttpResponse, HttpResponseServerError, Http404 from django.shortcuts import redirect +from social.apps.django_app.utils import load_strategy, load_backend +from .models import SAMLConfiguration def inactive_user_view(request): @@ -13,3 +18,21 @@ def inactive_user_view(request): # in a course. Otherwise, just redirect them to the dashboard, which displays a message # about activating their account. return redirect(request.GET.get('next', 'dashboard')) + + +def saml_metadata_view(request): + """ + Get the Service Provider metadata for this edx-platform instance. + You must send this XML to any Shibboleth Identity Provider that you wish to use. + """ + if not SAMLConfiguration.is_enabled(): + raise Http404 + complete_url = reverse('social:complete', args=("tpa-saml", )) + if settings.APPEND_SLASH and not complete_url.endswith('/'): + complete_url = complete_url + '/' # Required for consistency + saml_backend = load_backend(load_strategy(request), "tpa-saml", redirect_uri=complete_url) + metadata, errors = saml_backend.generate_metadata_xml() + + if not errors: + return HttpResponse(content=metadata, content_type='text/xml') + return HttpResponseServerError(content=', '.join(errors)) diff --git a/common/lib/safe_lxml/safe_lxml/etree.py b/common/lib/safe_lxml/safe_lxml/etree.py index 83052b22b6..97bc0b7547 100644 --- a/common/lib/safe_lxml/safe_lxml/etree.py +++ b/common/lib/safe_lxml/safe_lxml/etree.py @@ -9,7 +9,7 @@ For processing xml always prefer this over using lxml.etree directly. from lxml.etree import * # pylint: disable=wildcard-import, unused-wildcard-import from lxml.etree import XMLParser as _XMLParser -from lxml.etree import _ElementTree # pylint: disable=unused-import +from lxml.etree import _Element, _ElementTree # pylint: disable=unused-import, no-name-in-module # This should be imported after lxml.etree so that it overrides the following attributes. from defusedxml.lxml import parse, fromstring, XML diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 5f6ed8f8a0..4cb4ccfd40 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -1754,6 +1754,7 @@ class CombinedSystem(object): integrate it into a larger whole. """ + context = context or {} if view_name in PREVIEW_VIEWS: block = self._get_student_block(block) diff --git a/common/test/acceptance/pages/lms/fields.py b/common/test/acceptance/pages/lms/fields.py index 803f5a7886..014aa11bdb 100644 --- a/common/test/acceptance/pages/lms/fields.py +++ b/common/test/acceptance/pages/lms/fields.py @@ -211,6 +211,15 @@ class FieldsMixin(object): query = self.q(css='.u-field-link-title-{}'.format(field_id)) return query.text[0] if query.present else None + def wait_for_link_title_for_link_field(self, field_id, expected_title): + """ + Wait until the title of the specified link field equals expected_title. + """ + return EmptyPromise( + lambda: self.link_title_for_link_field(field_id) == expected_title, + "Link field with link title \"{0}\" is visible.".format(expected_title) + ).fulfill() + def click_on_link_in_link_field(self, field_id): """ Click the link in a link field. diff --git a/common/test/acceptance/pages/lms/login_and_register.py b/common/test/acceptance/pages/lms/login_and_register.py index b61e25c547..9cbd6af99f 100644 --- a/common/test/acceptance/pages/lms/login_and_register.py +++ b/common/test/acceptance/pages/lms/login_and_register.py @@ -232,7 +232,7 @@ class CombinedLoginAndRegisterPage(PageObject): Only the "Dummy" provider is used for bok choy because it is the only one that doesn't send traffic to external servers. """ - self.q(css="button.{}-Dummy".format(self.current_form)).click() + self.q(css="button.{}-oa2-dummy".format(self.current_form)).click() def password_reset(self, email): """Navigates to, fills in, and submits the password reset form. @@ -281,6 +281,8 @@ class CombinedLoginAndRegisterPage(PageObject): return "login" elif self.q(css=".js-reset").visible: return "password-reset" + elif self.q(css=".proceed-button").visible: + return "hinted-login" @property def email_value(self): @@ -335,3 +337,9 @@ class CombinedLoginAndRegisterPage(PageObject): return (True, msg_element.text[0]) return (False, None) return Promise(_check_func, "Result of third party auth is visible").fulfill() + + @property + def hinted_login_prompt(self): + """Get the message displayed to the user on the hinted-login form""" + if self.q(css=".wrapper-other-login .instructions").visible: + return self.q(css=".wrapper-other-login .instructions").text[0] diff --git a/common/test/acceptance/tests/lms/test_account_settings.py b/common/test/acceptance/tests/lms/test_account_settings.py index c9ab3eb051..efdcd1c00b 100644 --- a/common/test/acceptance/tests/lms/test_account_settings.py +++ b/common/test/acceptance/tests/lms/test_account_settings.py @@ -437,9 +437,10 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest): Currently there is no way to test the whole authentication process because that would require accounts with the providers. """ - for field_id, title, link_title in [ - ['auth-facebook', 'Facebook', 'Link'], - ['auth-google', 'Google', 'Link'], - ]: + providers = ( + ['auth-oa2-facebook', 'Facebook', 'Link'], + ['auth-oa2-google-oauth2', 'Google', 'Link'], + ) + for field_id, title, link_title in providers: self.assertEqual(self.account_settings_page.title_for_field(field_id), title) self.assertEqual(self.account_settings_page.link_title_for_link_field(field_id), link_title) diff --git a/common/test/acceptance/tests/lms/test_lms.py b/common/test/acceptance/tests/lms/test_lms.py index ad885e34dc..00eb2b33cc 100644 --- a/common/test/acceptance/tests/lms/test_lms.py +++ b/common/test/acceptance/tests/lms/test_lms.py @@ -164,9 +164,43 @@ class LoginFromCombinedPageTest(UniqueCourseTest): self.dashboard_page.wait_for_page() - # Now unlink the account (To test the account settings view and also to prevent cross-test side effects) + self._unlink_dummy_account() + + def test_hinted_login(self): + """ Test the login page when coming from course URL that specified which third party provider to use """ + # Create a user account and link it to third party auth with the dummy provider: + AutoAuthPage(self.browser, course_id=self.course_id).visit() + self._link_dummy_account() + LogoutPage(self.browser).visit() + + # When not logged in, try to load a course URL that includes the provider hint ?tpa_hint=... + course_page = CoursewarePage(self.browser, self.course_id) + self.browser.get(course_page.url + '?tpa_hint=oa2-dummy') + + # We should now be redirected to the login page + self.login_page.wait_for_page() + self.assertIn("Would you like to sign in using your Dummy credentials?", self.login_page.hinted_login_prompt) + self.login_page.click_third_party_dummy_provider() + + # We should now be redirected to the course page + course_page.wait_for_page() + + self._unlink_dummy_account() + + def _link_dummy_account(self): + """ Go to Account Settings page and link the user's account to the Dummy provider """ account_settings = AccountSettingsPage(self.browser).visit() - field_id = "auth-dummy" + field_id = "auth-oa2-dummy" + account_settings.wait_for_field(field_id) + self.assertEqual("Link", account_settings.link_title_for_link_field(field_id)) + account_settings.click_on_link_in_link_field(field_id) + account_settings.wait_for_link_title_for_link_field(field_id, "Unlink") + + def _unlink_dummy_account(self): + """ Verify that the 'Dummy' third party auth provider is linked, then unlink it """ + # This must be done after linking the account, or we'll get cross-test side effects + account_settings = AccountSettingsPage(self.browser).visit() + field_id = "auth-oa2-dummy" account_settings.wait_for_field(field_id) self.assertEqual("Unlink", account_settings.link_title_for_link_field(field_id)) account_settings.click_on_link_in_link_field(field_id) @@ -305,7 +339,7 @@ class RegisterFromCombinedPageTest(UniqueCourseTest): # Now unlink the account (To test the account settings view and also to prevent cross-test side effects) account_settings = AccountSettingsPage(self.browser).visit() - field_id = "auth-dummy" + field_id = "auth-oa2-dummy" account_settings.wait_for_field(field_id) self.assertEqual("Unlink", account_settings.link_title_for_link_field(field_id)) account_settings.click_on_link_in_link_field(field_id) diff --git a/common/test/db_fixtures/third_party_auth.json b/common/test/db_fixtures/third_party_auth.json new file mode 100644 index 0000000000..3042ebbb66 --- /dev/null +++ b/common/test/db_fixtures/third_party_auth.json @@ -0,0 +1,47 @@ +[ + { + "pk": 1, + "model": "third_party_auth.oauth2providerconfig", + "fields": { + "enabled": true, + "change_date": "2001-02-03T04:05:06Z", + "changed_by": null, + "name": "Google", + "icon_class": "fa-google-plus", + "backend_name": "google-oauth2", + "key": "test", + "secret": "test", + "other_settings": "{}" + } + }, + { + "pk": 2, + "model": "third_party_auth.oauth2providerconfig", + "fields": { + "enabled": true, + "change_date": "2001-02-03T04:05:06Z", + "changed_by": null, + "name": "Facebook", + "icon_class": "fa-facebook", + "backend_name": "facebook", + "key": "test", + "secret": "test", + "other_settings": "{}" + } + }, + { + "pk": 3, + "model": "third_party_auth.oauth2providerconfig", + "fields": { + "enabled": true, + "change_date": "2001-02-03T04:05:06Z", + "changed_by": null, + "name": "Dummy", + "icon_class": "fa-sign-in", + "backend_name": "dummy", + "key": "", + "secret": "", + "other_settings": "{}" + } + } +] diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py index 5c46647dfa..786c3427b7 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/lms/djangoapps/student_account/test/test_views.py @@ -23,7 +23,7 @@ from openedx.core.djangoapps.user_api.accounts.api import activate_account, crea from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH from student.tests.factories import CourseModeFactory, UserFactory from student_account.views import account_settings_context -from third_party_auth.tests.testutil import simulate_running_pipeline +from third_party_auth.tests.testutil import simulate_running_pipeline, ThirdPartyAuthTestMixin from util.testing import UrlResetMixin from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -204,7 +204,7 @@ class StudentAccountUpdateTest(UrlResetMixin, TestCase): @ddt.ddt -class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase): +class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMixin, ModuleStoreTestCase): """ Tests for the student account views that update the user's account information. """ USERNAME = "bob" @@ -214,6 +214,9 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase) @mock.patch.dict(settings.FEATURES, {'EMBARGO': True}) def setUp(self): super(StudentAccountLoginAndRegistrationTest, self).setUp('embargo') + # For these tests, two third party auth providers are enabled by default: + self.configure_google_provider(enabled=True) + self.configure_facebook_provider(enabled=True) @ddt.data( ("account_login", "login"), @@ -290,7 +293,7 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase) @ddt.unpack def test_third_party_auth(self, url_name, current_backend, current_provider): params = [ - ('course_id', 'edX/DemoX/Demo_Course'), + ('course_id', 'course-v1:Org+Course+Run'), ('enrollment_action', 'enroll'), ('course_mode', 'honor'), ('email_opt_in', 'true'), @@ -310,12 +313,14 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase) # This relies on the THIRD_PARTY_AUTH configuration in the test settings expected_providers = [ { + "id": "oa2-facebook", "name": "Facebook", "iconClass": "fa-facebook", "loginUrl": self._third_party_login_url("facebook", "login", params), "registerUrl": self._third_party_login_url("facebook", "register", params) }, { + "id": "oa2-google-oauth2", "name": "Google", "iconClass": "fa-google-plus", "loginUrl": self._third_party_login_url("google-oauth2", "login", params), @@ -324,6 +329,11 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase) ] self._assert_third_party_auth_data(response, current_backend, current_provider, expected_providers) + def test_hinted_login(self): + params = [("next", "/courses/something/?tpa_hint=oa2-google-oauth2")] + response = self.client.get(reverse('account_login'), params) + self.assertContains(response, "data-third-party-auth-hint='oa2-google-oauth2'") + @override_settings(SITE_NAME=settings.MICROSITE_TEST_HOSTNAME) def test_microsite_uses_old_login_page(self): # Retrieve the login page from a microsite domain @@ -347,11 +357,15 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase) def _assert_third_party_auth_data(self, response, current_backend, current_provider, providers): """Verify that third party auth info is rendered correctly in a DOM data attribute. """ + finish_auth_url = None + if current_backend: + finish_auth_url = reverse("social:complete", kwargs={"backend": current_backend}) + "?" auth_info = markupsafe.escape( json.dumps({ "currentProvider": current_provider, "providers": providers, - "finishAuthUrl": "/auth/complete/{}?".format(current_backend) if current_backend else None, + "secondaryProviders": [], + "finishAuthUrl": finish_auth_url, "errorMessage": None, }) ) @@ -382,7 +396,7 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase) }) -class AccountSettingsViewTest(TestCase): +class AccountSettingsViewTest(ThirdPartyAuthTestMixin, TestCase): """ Tests for the account settings view. """ USERNAME = 'student' @@ -406,6 +420,10 @@ class AccountSettingsViewTest(TestCase): self.request = RequestFactory() self.request.user = self.user + # For these tests, two third party auth providers are enabled by default: + self.configure_google_provider(enabled=True) + self.configure_facebook_provider(enabled=True) + # Python-social saves auth failure notifcations in Django messages. # See pipeline.get_duplicate_provider() for details. self.request.COOKIES = {} @@ -432,7 +450,7 @@ class AccountSettingsViewTest(TestCase): context['user_preferences_api_url'], reverse('preferences_api', kwargs={'username': self.user.username}) ) - self.assertEqual(context['duplicate_provider'].BACKEND_CLASS.name, 'facebook') + self.assertEqual(context['duplicate_provider'], 'facebook') self.assertEqual(context['auth']['providers'][0]['name'], 'Facebook') self.assertEqual(context['auth']['providers'][1]['name'], 'Google') diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py index dc178285ff..76602b1337 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -2,6 +2,7 @@ import logging import json +import urlparse from django.conf import settings from django.contrib import messages @@ -77,12 +78,26 @@ def login_and_registration_form(request, initial_mode="login"): if ext_auth_response is not None: return ext_auth_response + # Our ?next= URL may itself contain a parameter 'tpa_hint=x' that we need to check. + # If present, we display a login page focused on third-party auth with that provider. + third_party_auth_hint = None + if '?' in redirect_to: + try: + next_args = urlparse.parse_qs(urlparse.urlparse(redirect_to).query) + provider_id = next_args['tpa_hint'][0] + if third_party_auth.provider.Registry.get(provider_id=provider_id): + third_party_auth_hint = provider_id + initial_mode = "hinted_login" + except (KeyError, ValueError, IndexError): + pass + # Otherwise, render the combined login/registration page context = { 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header 'disable_courseware_js': True, 'initial_mode': initial_mode, 'third_party_auth': json.dumps(_third_party_auth_context(request, redirect_to)), + 'third_party_auth_hint': third_party_auth_hint or '', 'platform_name': settings.PLATFORM_NAME, 'responsive': True, @@ -164,41 +179,45 @@ def _third_party_auth_context(request, redirect_to): context = { "currentProvider": None, "providers": [], + "secondaryProviders": [], "finishAuthUrl": None, "errorMessage": None, } if third_party_auth.is_enabled(): - context["providers"] = [ - { - "name": enabled.NAME, - "iconClass": enabled.ICON_CLASS, + for enabled in third_party_auth.provider.Registry.enabled(): + info = { + "id": enabled.provider_id, + "name": enabled.name, + "iconClass": enabled.icon_class, "loginUrl": pipeline.get_login_url( - enabled.NAME, + enabled.provider_id, pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to, ), "registerUrl": pipeline.get_login_url( - enabled.NAME, + enabled.provider_id, pipeline.AUTH_ENTRY_REGISTER, redirect_url=redirect_to, ), } - for enabled in third_party_auth.provider.Registry.enabled() - ] + context["providers" if not enabled.secondary else "secondaryProviders"].append(info) running_pipeline = pipeline.get(request) if running_pipeline is not None: - current_provider = third_party_auth.provider.Registry.get_by_backend_name( - running_pipeline.get('backend') - ) - context["currentProvider"] = current_provider.NAME - context["finishAuthUrl"] = pipeline.get_complete_url(current_provider.BACKEND_CLASS.name) + current_provider = third_party_auth.provider.Registry.get_from_pipeline(running_pipeline) + context["currentProvider"] = current_provider.name + context["finishAuthUrl"] = pipeline.get_complete_url(current_provider.backend_name) + + if current_provider.skip_registration_form: + # As a reliable way of "skipping" the registration form, we just submit it automatically + context["autoSubmitRegForm"] = True # Check for any error messages we may want to display: for msg in messages.get_messages(request): if msg.extra_tags.split()[0] == "social-auth": - context['errorMessage'] = unicode(msg) + # msg may or may not be translated. Try translating [again] in case we are able to: + context['errorMessage'] = _(unicode(msg)) # pylint: disable=translation-of-non-string break return context @@ -370,19 +389,20 @@ def account_settings_context(request): auth_states = pipeline.get_provider_user_states(user) context['auth']['providers'] = [{ - 'name': state.provider.NAME, # The name of the provider e.g. Facebook + 'id': state.provider.provider_id, + 'name': state.provider.name, # The name of the provider e.g. Facebook 'connected': state.has_account, # Whether the user's edX account is connected with the provider. # If the user is not connected, they should be directed to this page to authenticate # with the particular provider. 'connect_url': pipeline.get_login_url( - state.provider.NAME, + state.provider.provider_id, pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS, # The url the user should be directed to after the auth process has completed. redirect_url=reverse('account_settings'), ), # If the user is connected, sending a POST request to this url removes the connection # information for this provider from their edX account. - 'disconnect_url': pipeline.get_disconnect_url(state.provider.NAME), + 'disconnect_url': pipeline.get_disconnect_url(state.provider.provider_id, state.association_id), } for state in auth_states] return context diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 0af1c480d8..faef1166e5 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -16,6 +16,7 @@ Common traits: # and throws spurious errors. Therefore, we disable invalid-name checking. # pylint: disable=invalid-name +import datetime import json from .common import * @@ -107,6 +108,7 @@ CELERY_QUEUES = { if os.environ.get('QUEUE') == 'high_mem': CELERYD_MAX_TASKS_PER_CHILD = 1 +CELERYBEAT_SCHEDULE = {} # For scheduling tasks, entries can be added to this dict ########################## NON-SECURE ENV CONFIG ############################## # Things like server locations, ports, etc. @@ -536,10 +538,27 @@ TIME_ZONE_DISPLAYED_FOR_DEADLINES = ENV_TOKENS.get("TIME_ZONE_DISPLAYED_FOR_DEAD X_FRAME_OPTIONS = ENV_TOKENS.get('X_FRAME_OPTIONS', X_FRAME_OPTIONS) ##### Third-party auth options ################################################ -THIRD_PARTY_AUTH = AUTH_TOKENS.get('THIRD_PARTY_AUTH', THIRD_PARTY_AUTH) +if FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): + AUTHENTICATION_BACKENDS = ( + ENV_TOKENS.get('THIRD_PARTY_AUTH_BACKENDS', [ + 'social.backends.google.GoogleOAuth2', + 'social.backends.linkedin.LinkedinOAuth2', + 'social.backends.facebook.FacebookOAuth2', + 'third_party_auth.saml.SAMLAuthBackend', + ]) + list(AUTHENTICATION_BACKENDS) + ) -# The reduced session expiry time during the third party login pipeline. (Value in seconds) -SOCIAL_AUTH_PIPELINE_TIMEOUT = ENV_TOKENS.get('SOCIAL_AUTH_PIPELINE_TIMEOUT', 600) + # The reduced session expiry time during the third party login pipeline. (Value in seconds) + SOCIAL_AUTH_PIPELINE_TIMEOUT = ENV_TOKENS.get('SOCIAL_AUTH_PIPELINE_TIMEOUT', 600) + + # third_party_auth config moved to ConfigurationModels. This is for data migration only: + THIRD_PARTY_AUTH_OLD_CONFIG = AUTH_TOKENS.get('THIRD_PARTY_AUTH', None) + + if ENV_TOKENS.get('THIRD_PARTY_AUTH_SAML_FETCH_PERIOD_HOURS', 24) is not None: + CELERYBEAT_SCHEDULE['refresh-saml-metadata'] = { + 'task': 'third_party_auth.fetch_saml_metadata', + 'schedule': datetime.timedelta(hours=ENV_TOKENS.get('THIRD_PARTY_AUTH_SAML_FETCH_PERIOD_HOURS', 24)), + } ##### OAUTH2 Provider ############## if FEATURES.get('ENABLE_OAUTH2_PROVIDER'): diff --git a/lms/envs/bok_choy.auth.json b/lms/envs/bok_choy.auth.json index 85a19928cc..719f72ccf9 100644 --- a/lms/envs/bok_choy.auth.json +++ b/lms/envs/bok_choy.auth.json @@ -117,17 +117,6 @@ "username": "lms" }, "SECRET_KEY": "", - "THIRD_PARTY_AUTH": { - "Dummy": {}, - "Google": { - "SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": "test", - "SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test" - }, - "Facebook": { - "SOCIAL_AUTH_FACEBOOK_KEY": "test", - "SOCIAL_AUTH_FACEBOOK_SECRET": "test" - } - }, "DJFS": { "type": "s3fs", "bucket": "test", diff --git a/lms/envs/bok_choy.env.json b/lms/envs/bok_choy.env.json index 8fde6044b5..5be8f73c84 100644 --- a/lms/envs/bok_choy.env.json +++ b/lms/envs/bok_choy.env.json @@ -79,7 +79,6 @@ "ENABLE_INSTRUCTOR_ANALYTICS": true, "ENABLE_S3_GRADE_DOWNLOADS": true, "ENABLE_THIRD_PARTY_AUTH": true, - "ENABLE_DUMMY_THIRD_PARTY_AUTH_PROVIDER": true, "ENABLE_COMBINED_LOGIN_REGISTRATION": true, "PREVIEW_LMS_BASE": "localhost:8003", "SUBDOMAIN_BRANDING": false, @@ -119,6 +118,13 @@ "SYSLOG_SERVER": "", "TECH_SUPPORT_EMAIL": "technical@example.com", "THEME_NAME": "", + "THIRD_PARTY_AUTH_BACKENDS": [ + "social.backends.google.GoogleOAuth2", + "social.backends.linkedin.LinkedinOAuth2", + "social.backends.facebook.FacebookOAuth2", + "third_party_auth.dummy.DummyBackend", + "third_party_auth.saml.SAMLAuthBackend" + ], "TIME_ZONE": "America/New_York", "WIKI_ENABLED": true } diff --git a/lms/envs/common.py b/lms/envs/common.py index d454dec890..03193858c5 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1271,9 +1271,11 @@ student_account_js = [ 'js/student_account/models/PasswordResetModel.js', 'js/student_account/views/FormView.js', 'js/student_account/views/LoginView.js', + 'js/student_account/views/HintedLoginView.js', 'js/student_account/views/RegisterView.js', 'js/student_account/views/PasswordResetView.js', 'js/student_account/views/AccessView.js', + 'js/student_account/views/InstitutionLoginView.js', 'js/student_account/accessApp.js', ] @@ -2385,10 +2387,6 @@ for app_name in OPTIONAL_APPS: continue INSTALLED_APPS += (app_name,) -# Stub for third_party_auth options. -# See common/djangoapps/third_party_auth/settings.py for configuration details. -THIRD_PARTY_AUTH = {} - ### ADVANCED_SECURITY_CONFIG # Empty by default ADVANCED_SECURITY_CONFIG = {} diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 4960594a87..808df44c33 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -170,6 +170,10 @@ FEATURES['STORE_BILLING_INFO'] = True FEATURES['ENABLE_PAID_COURSE_REGISTRATION'] = True FEATURES['ENABLE_COSMETIC_DISPLAY_PRICE'] = True +########################## Third Party Auth ####################### + +if FEATURES.get('ENABLE_THIRD_PARTY_AUTH') and 'third_party_auth.dummy.DummyBackend' not in AUTHENTICATION_BACKENDS: + AUTHENTICATION_BACKENDS = ['third_party_auth.dummy.DummyBackend'] + list(AUTHENTICATION_BACKENDS) ##################################################################### # See if the developer has any local overrides. diff --git a/lms/envs/test.py b/lms/envs/test.py index 4870c5513a..d84a0d2fd3 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -238,18 +238,13 @@ PASSWORD_COMPLEXITY = {} ######### Third-party auth ########## FEATURES['ENABLE_THIRD_PARTY_AUTH'] = True -THIRD_PARTY_AUTH = { - "Google": { - "SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": "test", - "SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test", - }, - "Facebook": { - "SOCIAL_AUTH_FACEBOOK_KEY": "test", - "SOCIAL_AUTH_FACEBOOK_SECRET": "test", - }, -} - -FEATURES['ENABLE_DUMMY_THIRD_PARTY_AUTH_PROVIDER'] = True +AUTHENTICATION_BACKENDS = ( + 'social.backends.google.GoogleOAuth2', + 'social.backends.linkedin.LinkedinOAuth2', + 'social.backends.facebook.FacebookOAuth2', + 'third_party_auth.dummy.DummyBackend', + 'third_party_auth.saml.SAMLAuthBackend', +) + AUTHENTICATION_BACKENDS ################################## OPENID ##################################### FEATURES['AUTH_USE_OPENID'] = True diff --git a/lms/startup.py b/lms/startup.py index 3add027f15..4ca979ce45 100644 --- a/lms/startup.py +++ b/lms/startup.py @@ -141,4 +141,4 @@ def enable_third_party_auth(): """ from third_party_auth import settings as auth_settings - auth_settings.apply_settings(settings.THIRD_PARTY_AUTH, settings) + auth_settings.apply_settings(settings) diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 169377d7b7..2640cd23d5 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -84,11 +84,13 @@ 'js/student_account/views/FormView': 'js/student_account/views/FormView', 'js/student_account/models/LoginModel': 'js/student_account/models/LoginModel', 'js/student_account/views/LoginView': 'js/student_account/views/LoginView', + 'js/student_account/views/InstitutionLoginView': 'js/student_account/views/InstitutionLoginView', 'js/student_account/models/PasswordResetModel': 'js/student_account/models/PasswordResetModel', 'js/student_account/views/PasswordResetView': 'js/student_account/views/PasswordResetView', 'js/student_account/models/RegisterModel': 'js/student_account/models/RegisterModel', 'js/student_account/views/RegisterView': 'js/student_account/views/RegisterView', 'js/student_account/views/AccessView': 'js/student_account/views/AccessView', + 'js/student_account/views/HintedLoginView': 'js/student_account/views/HintedLoginView', 'js/student_profile/profile': 'js/student_profile/profile', 'js/student_profile/views/learner_profile_fields': 'js/student_profile/views/learner_profile_fields', 'js/student_profile/views/learner_profile_factory': 'js/student_profile/views/learner_profile_factory', @@ -410,6 +412,14 @@ 'js/student_account/views/FormView' ] }, + 'js/student_account/views/InstitutionLoginView': { + exports: 'edx.student.account.InstitutionLoginView', + deps: [ + 'jquery', + 'underscore', + 'backbone' + ] + }, 'js/student_account/models/PasswordResetModel': { exports: 'edx.student.account.PasswordResetModel', deps: ['jquery', 'jquery.cookie', 'backbone'] @@ -439,6 +449,15 @@ 'js/student_account/views/FormView' ] }, + 'js/student_account/views/HintedLoginView': { + exports: 'edx.student.account.HintedLoginView', + deps: [ + 'jquery', + 'underscore', + 'backbone', + 'gettext' + ] + }, 'js/student_account/views/AccessView': { exports: 'edx.student.account.AccessView', deps: [ @@ -450,6 +469,7 @@ 'js/student_account/views/LoginView', 'js/student_account/views/PasswordResetView', 'js/student_account/views/RegisterView', + 'js/student_account/views/InstitutionLoginView', 'js/student_account/models/LoginModel', 'js/student_account/models/PasswordResetModel', 'js/student_account/models/RegisterModel', @@ -612,7 +632,9 @@ 'lms/include/js/spec/student_account/account_spec.js', 'lms/include/js/spec/student_account/access_spec.js', 'lms/include/js/spec/student_account/finish_auth_spec.js', + 'lms/include/js/spec/student_account/hinted_login_spec.js', 'lms/include/js/spec/student_account/login_spec.js', + 'lms/include/js/spec/student_account/institution_login_spec.js', 'lms/include/js/spec/student_account/register_spec.js', 'lms/include/js/spec/student_account/password_reset_spec.js', 'lms/include/js/spec/student_account/enrollment_spec.js', diff --git a/lms/static/js/spec/student_account/access_spec.js b/lms/static/js/spec/student_account/access_spec.js index a82da514e2..590c9df58f 100644 --- a/lms/static/js/spec/student_account/access_spec.js +++ b/lms/static/js/spec/student_account/access_spec.js @@ -58,6 +58,7 @@ define([ thirdPartyAuth: { currentProvider: null, providers: [], + secondaryProviders: [{name: "provider"}], finishAuthUrl: finishAuthUrl }, nextUrl: nextUrl, // undefined for default @@ -97,6 +98,8 @@ define([ TemplateHelpers.installTemplate('templates/student_account/register'); TemplateHelpers.installTemplate('templates/student_account/password_reset'); TemplateHelpers.installTemplate('templates/student_account/form_field'); + TemplateHelpers.installTemplate('templates/student_account/institution_login'); + TemplateHelpers.installTemplate('templates/student_account/institution_register'); // Stub analytics tracking window.analytics = jasmine.createSpyObj('analytics', ['track', 'page', 'pageview', 'trackLink']); @@ -135,6 +138,30 @@ define([ assertForms('#login-form', '#register-form'); }); + it('toggles between the login and institution login view', function() { + ajaxSpyAndInitialize(this, 'login'); + + // Simulate clicking on institution login button + $('#login-form .button-secondary-login[data-type="institution_login"]').click(); + assertForms('#institution_login-form', '#login-form'); + + // Simulate selection of the login form + selectForm('login'); + assertForms('#login-form', '#institution_login-form'); + }); + + it('toggles between the register and institution register view', function() { + ajaxSpyAndInitialize(this, 'register'); + + // Simulate clicking on institution login button + $('#register-form .button-secondary-login[data-type="institution_login"]').click(); + assertForms('#institution_login-form', '#register-form'); + + // Simulate selection of the login form + selectForm('register'); + assertForms('#register-form', '#institution_login-form'); + }); + it('displays the reset password form', function() { ajaxSpyAndInitialize(this, 'login'); diff --git a/lms/static/js/spec/student_account/account_settings_factory_spec.js b/lms/static/js/spec/student_account/account_settings_factory_spec.js index df1aaa658a..fc7483eaa5 100644 --- a/lms/static/js/spec/student_account/account_settings_factory_spec.js +++ b/lms/static/js/spec/student_account/account_settings_factory_spec.js @@ -32,12 +32,14 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers var AUTH_DATA = { 'providers': [ { + 'id': 'oa2-network1', 'name': "Network1", 'connected': true, 'connect_url': 'yetanother1.com/auth/connect', 'disconnect_url': 'yetanother1.com/auth/disconnect' }, { + 'id': 'oa2-network2', 'name': "Network2", 'connected': true, 'connect_url': 'yetanother2.com/auth/connect', diff --git a/lms/static/js/spec/student_account/hinted_login_spec.js b/lms/static/js/spec/student_account/hinted_login_spec.js new file mode 100644 index 0000000000..b6f346a56e --- /dev/null +++ b/lms/static/js/spec/student_account/hinted_login_spec.js @@ -0,0 +1,71 @@ +define([ + 'jquery', + 'underscore', + 'common/js/spec_helpers/template_helpers', + 'common/js/spec_helpers/ajax_helpers', + 'js/student_account/views/HintedLoginView', +], function($, _, TemplateHelpers, AjaxHelpers, HintedLoginView) { + 'use strict'; + describe('edx.student.account.HintedLoginView', function() { + + var view = null, + requests = null, + PLATFORM_NAME = 'edX', + THIRD_PARTY_AUTH = { + currentProvider: null, + providers: [ + { + id: 'oa2-google-oauth2', + name: 'Google', + iconClass: 'fa-google-plus', + loginUrl: '/auth/login/google-oauth2/?auth_entry=account_login', + registerUrl: '/auth/login/google-oauth2/?auth_entry=account_register' + }, + { + id: 'oa2-facebook', + name: 'Facebook', + iconClass: 'fa-facebook', + loginUrl: '/auth/login/facebook/?auth_entry=account_login', + registerUrl: '/auth/login/facebook/?auth_entry=account_register' + } + ] + }, + HINTED_PROVIDER = "oa2-google-oauth2"; + + var createHintedLoginView = function(test) { + // Initialize the login view + view = new HintedLoginView({ + thirdPartyAuth: THIRD_PARTY_AUTH, + hintedProvider: HINTED_PROVIDER, + platformName: PLATFORM_NAME + }); + + // Mock the redirect call + spyOn( view, 'redirect' ).andCallFake( function() {} ); + + view.render(); + }; + + beforeEach(function() { + setFixtures('
'); + TemplateHelpers.installTemplate('templates/student_account/hinted_login'); + }); + + it('displays a choice as two buttons', function() { + createHintedLoginView(this); + + expect($('.proceed-button.button-oa2-google-oauth2')).toBeVisible(); + expect($('.form-toggle')).toBeVisible(); + expect($('.proceed-button.button-oa2-facebook')).not.toBeVisible(); + }); + + it('redirects the user to the hinted provider if the user clicks the proceed button', function() { + createHintedLoginView(this); + + // Click the "Yes, proceed" button + $('.proceed-button').click(); + + expect(view.redirect).toHaveBeenCalledWith( '/auth/login/google-oauth2/?auth_entry=account_login' ); + }); + }); +}); diff --git a/lms/static/js/spec/student_account/institution_login_spec.js b/lms/static/js/spec/student_account/institution_login_spec.js new file mode 100644 index 0000000000..208c975550 --- /dev/null +++ b/lms/static/js/spec/student_account/institution_login_spec.js @@ -0,0 +1,80 @@ +define([ + 'jquery', + 'underscore', + 'common/js/spec_helpers/template_helpers', + 'js/student_account/views/InstitutionLoginView', +], function($, _, TemplateHelpers, InstitutionLoginView) { + 'use strict'; + describe('edx.student.account.InstitutionLoginView', function() { + + var view = null, + PLATFORM_NAME = 'edX', + THIRD_PARTY_AUTH = { + currentProvider: null, + providers: [], + secondaryProviders: [ + { + id: 'oa2-google-oauth2', + name: 'Google', + iconClass: 'fa-google-plus', + loginUrl: '/auth/login/google-oauth2/?auth_entry=account_login', + registerUrl: '/auth/login/google-oauth2/?auth_entry=account_register' + }, + { + id: 'oa2-facebook', + name: 'Facebook', + iconClass: 'fa-facebook', + loginUrl: '/auth/login/facebook/?auth_entry=account_login', + registerUrl: '/auth/login/facebook/?auth_entry=account_register' + } + ] + }; + + var createInstLoginView = function(mode) { + // Initialize the login view + view = new InstitutionLoginView({ + mode: mode, + thirdPartyAuth: THIRD_PARTY_AUTH, + platformName: PLATFORM_NAME + }); + view.render(); + }; + + beforeEach(function() { + setFixtures('
'); + TemplateHelpers.installTemplate('templates/student_account/institution_login'); + TemplateHelpers.installTemplate('templates/student_account/institution_register'); + }); + + it('displays a list of providers', function() { + createInstLoginView('login'); + expect($('#institution_login-form').html()).not.toBe(""); + var $google = $('li a:contains("Google")'); + expect($google).toBeVisible(); + expect($google).toHaveAttr( + 'href', '/auth/login/google-oauth2/?auth_entry=account_login' + ); + var $facebook = $('li a:contains("Facebook")'); + expect($facebook).toBeVisible(); + expect($facebook).toHaveAttr( + 'href', '/auth/login/facebook/?auth_entry=account_login' + ); + }); + + it('displays a list of providers', function() { + createInstLoginView('register'); + expect($('#institution_login-form').html()).not.toBe(""); + var $google = $('li a:contains("Google")'); + expect($google).toBeVisible(); + expect($google).toHaveAttr( + 'href', '/auth/login/google-oauth2/?auth_entry=account_register' + ); + var $facebook = $('li a:contains("Facebook")'); + expect($facebook).toBeVisible(); + expect($facebook).toHaveAttr( + 'href', '/auth/login/facebook/?auth_entry=account_register' + ); + }); + + }); +}); diff --git a/lms/static/js/spec/student_account/login_spec.js b/lms/static/js/spec/student_account/login_spec.js index 30ff0f10d3..d61f49be87 100644 --- a/lms/static/js/spec/student_account/login_spec.js +++ b/lms/static/js/spec/student_account/login_spec.js @@ -25,12 +25,14 @@ define([ currentProvider: null, providers: [ { + id: 'oa2-google-oauth2', name: 'Google', iconClass: 'fa-google-plus', loginUrl: '/auth/login/google-oauth2/?auth_entry=account_login', registerUrl: '/auth/login/google-oauth2/?auth_entry=account_register' }, { + id: 'oa2-facebook', name: 'Facebook', iconClass: 'fa-facebook', loginUrl: '/auth/login/facebook/?auth_entry=account_login', @@ -195,8 +197,8 @@ define([ createLoginView(this); // Verify that Google and Facebook registration buttons are displayed - expect($('.button-Google')).toBeVisible(); - expect($('.button-Facebook')).toBeVisible(); + expect($('.button-oa2-google-oauth2')).toBeVisible(); + expect($('.button-oa2-facebook')).toBeVisible(); }); it('displays a link to the password reset form', function() { diff --git a/lms/static/js/spec/student_account/register_spec.js b/lms/static/js/spec/student_account/register_spec.js index 67c5a65f2a..ac9064e376 100644 --- a/lms/static/js/spec/student_account/register_spec.js +++ b/lms/static/js/spec/student_account/register_spec.js @@ -32,12 +32,14 @@ define([ currentProvider: null, providers: [ { + id: 'oa2-google-oauth2', name: 'Google', iconClass: 'fa-google-plus', loginUrl: '/auth/login/google-oauth2/?auth_entry=account_login', registerUrl: '/auth/login/google-oauth2/?auth_entry=account_register' }, { + id: 'oa2-facebook', name: 'Facebook', iconClass: 'fa-facebook', loginUrl: '/auth/login/facebook/?auth_entry=account_login', @@ -284,8 +286,8 @@ define([ createRegisterView(this); // Verify that Google and Facebook registration buttons are displayed - expect($('.button-Google')).toBeVisible(); - expect($('.button-Facebook')).toBeVisible(); + expect($('.button-oa2-google-oauth2')).toBeVisible(); + expect($('.button-oa2-facebook')).toBeVisible(); }); it('validates registration form fields', function() { diff --git a/lms/static/js/student_account/accessApp.js b/lms/static/js/student_account/accessApp.js index 7c6579b536..2510bc1cff 100644 --- a/lms/static/js/student_account/accessApp.js +++ b/lms/static/js/student_account/accessApp.js @@ -11,6 +11,7 @@ var edx = edx || {}; return new edx.student.account.AccessView({ mode: container.data('initial-mode'), thirdPartyAuth: container.data('third-party-auth'), + thirdPartyAuthHint: container.data('third-party-auth-hint'), nextUrl: container.data('next-url'), platformName: container.data('platform-name'), loginFormDesc: container.data('login-form-desc'), diff --git a/lms/static/js/student_account/views/AccessView.js b/lms/static/js/student_account/views/AccessView.js index 9cf1dcae15..a909c61fae 100644 --- a/lms/static/js/student_account/views/AccessView.js +++ b/lms/static/js/student_account/views/AccessView.js @@ -18,7 +18,9 @@ var edx = edx || {}; subview: { login: {}, register: {}, - passwordHelp: {} + passwordHelp: {}, + institutionLogin: {}, + hintedLogin: {} }, nextUrl: '/dashboard', @@ -42,6 +44,8 @@ var edx = edx || {}; providers: [] }; + this.thirdPartyAuthHint = obj.thirdPartyAuthHint || null; + if (obj.nextUrl) { // Ensure that the next URL is internal for security reasons if ( ! window.isExternal( obj.nextUrl ) ) { @@ -52,7 +56,9 @@ var edx = edx || {}; this.formDescriptions = { login: obj.loginFormDesc, register: obj.registrationFormDesc, - reset: obj.passwordResetFormDesc + reset: obj.passwordResetFormDesc, + institution_login: null, + hinted_login: null }; this.platformName = obj.platformName; @@ -148,6 +154,26 @@ var edx = edx || {}; // Listen for 'auth-complete' event so we can enroll/redirect the user appropriately. this.listenTo( this.subview.register, 'auth-complete', this.authComplete ); + }, + + institution_login: function ( unused ) { + this.subview.institutionLogin = new edx.student.account.InstitutionLoginView({ + thirdPartyAuth: this.thirdPartyAuth, + platformName: this.platformName, + mode: this.activeForm + }); + + this.subview.institutionLogin.render(); + }, + + hinted_login: function ( unused ) { + this.subview.hintedLogin = new edx.student.account.HintedLoginView({ + thirdPartyAuth: this.thirdPartyAuth, + hintedProvider: this.thirdPartyAuthHint, + platformName: this.platformName + }); + + this.subview.hintedLogin.render(); } }, @@ -180,9 +206,11 @@ var edx = edx || {}; category: 'user-engagement' }); - if ( !this.form.isLoaded( $form ) ) { + // Load the form. Institution login is always refreshed since it changes based on the previous form. + if ( !this.form.isLoaded( $form ) || type == "institution_login") { this.loadForm( type ); } + this.activeForm = type; this.element.hide( $(this.el).find('.submission-success') ); this.element.hide( $(this.el).find('.form-wrapper') ); @@ -190,11 +218,13 @@ var edx = edx || {}; this.element.scrollTop( $anchor ); // Update url without reloading page - History.pushState( null, document.title, '/' + type + queryStr ); + if (type != "institution_login") { + History.pushState( null, document.title, '/' + type + queryStr ); + } analytics.page( 'login_and_registration', type ); // Focus on the form - document.getElementById(type).focus(); + $("#" + type).focus(); }, /** diff --git a/lms/static/js/student_account/views/FormView.js b/lms/static/js/student_account/views/FormView.js index 12f0d51100..989be0bc86 100644 --- a/lms/static/js/student_account/views/FormView.js +++ b/lms/static/js/student_account/views/FormView.js @@ -215,7 +215,9 @@ var edx = edx || {}; submitForm: function( event ) { var data = this.getFormData(); - event.preventDefault(); + if (!_.isUndefined(event)) { + event.preventDefault(); + } this.toggleDisableButton(true); diff --git a/lms/static/js/student_account/views/HintedLoginView.js b/lms/static/js/student_account/views/HintedLoginView.js new file mode 100644 index 0000000000..ae178e00ef --- /dev/null +++ b/lms/static/js/student_account/views/HintedLoginView.js @@ -0,0 +1,52 @@ +var edx = edx || {}; + +(function($, _, gettext) { + 'use strict'; + + edx.student = edx.student || {}; + edx.student.account = edx.student.account || {}; + + edx.student.account.HintedLoginView = Backbone.View.extend({ + el: '#hinted-login-form', + + tpl: '#hinted_login-tpl', + + events: { + 'click .proceed-button': 'proceedWithHintedAuth' + }, + + formType: 'hinted-login', + + initialize: function( data ) { + this.tpl = $(this.tpl).html(); + this.providers = data.thirdPartyAuth.providers || []; + this.hintedProvider = _.findWhere(this.providers, {id: data.hintedProvider}) + this.platformName = data.platformName; + + }, + + render: function() { + $(this.el).html( _.template( this.tpl, { + // We pass the context object to the template so that + // we can perform variable interpolation using sprintf + providers: this.providers, + platformName: this.platformName, + hintedProvider: this.hintedProvider + })); + + return this; + }, + + proceedWithHintedAuth: function( event ) { + this.redirect(this.hintedProvider.loginUrl); + }, + + /** + * Redirect to a URL. Mainly useful for mocking out in tests. + * @param {string} url The URL to redirect to. + */ + redirect: function( url ) { + window.location.href = url; + } + }); +})(jQuery, _, gettext); diff --git a/lms/static/js/student_account/views/InstitutionLoginView.js b/lms/static/js/student_account/views/InstitutionLoginView.js new file mode 100644 index 0000000000..524e3a63b3 --- /dev/null +++ b/lms/static/js/student_account/views/InstitutionLoginView.js @@ -0,0 +1,30 @@ +var edx = edx || {}; + +(function($, _, Backbone) { + 'use strict'; + + edx.student = edx.student || {}; + edx.student.account = edx.student.account || {}; + + edx.student.account.InstitutionLoginView = Backbone.View.extend({ + el: '#institution_login-form', + + initialize: function( data ) { + var tpl = data.mode == "register" ? '#institution_register-tpl' : '#institution_login-tpl'; + this.tpl = $(tpl).html(); + this.providers = data.thirdPartyAuth.secondaryProviders || []; + this.platformName = data.platformName; + }, + + render: function() { + $(this.el).html( _.template( this.tpl, { + // We pass the context object to the template so that + // we can perform variable interpolation using sprintf + providers: this.providers, + platformName: this.platformName + })); + + return this; + } + }); +})(jQuery, _, Backbone); diff --git a/lms/static/js/student_account/views/LoginView.js b/lms/static/js/student_account/views/LoginView.js index 79eb44d1ce..d54c65fddb 100644 --- a/lms/static/js/student_account/views/LoginView.js +++ b/lms/static/js/student_account/views/LoginView.js @@ -25,6 +25,9 @@ var edx = edx || {}; preRender: function( data ) { this.providers = data.thirdPartyAuth.providers || []; + this.hasSecondaryProviders = ( + data.thirdPartyAuth.secondaryProviders && data.thirdPartyAuth.secondaryProviders.length + ); this.currentProvider = data.thirdPartyAuth.currentProvider || ''; this.errorMessage = data.thirdPartyAuth.errorMessage || ''; this.platformName = data.platformName; @@ -45,6 +48,7 @@ var edx = edx || {}; currentProvider: this.currentProvider, errorMessage: this.errorMessage, providers: this.providers, + hasSecondaryProviders: this.hasSecondaryProviders, platformName: this.platformName } })); diff --git a/lms/static/js/student_account/views/RegisterView.js b/lms/static/js/student_account/views/RegisterView.js index 177bfe51da..294704521b 100644 --- a/lms/static/js/student_account/views/RegisterView.js +++ b/lms/static/js/student_account/views/RegisterView.js @@ -22,9 +22,13 @@ var edx = edx || {}; preRender: function( data ) { this.providers = data.thirdPartyAuth.providers || []; + this.hasSecondaryProviders = ( + data.thirdPartyAuth.secondaryProviders && data.thirdPartyAuth.secondaryProviders.length + ); this.currentProvider = data.thirdPartyAuth.currentProvider || ''; this.errorMessage = data.thirdPartyAuth.errorMessage || ''; this.platformName = data.platformName; + this.autoSubmit = data.thirdPartyAuth.autoSubmitRegForm; this.listenTo( this.model, 'sync', this.saveSuccess ); }, @@ -41,12 +45,19 @@ var edx = edx || {}; currentProvider: this.currentProvider, errorMessage: this.errorMessage, providers: this.providers, + hasSecondaryProviders: this.hasSecondaryProviders, platformName: this.platformName } })); this.postRender(); + if (this.autoSubmit) { + $(this.el).hide(); + $('#register-honor_code').prop('checked', true); + this.submitForm(); + } + return this; }, @@ -63,6 +74,7 @@ var edx = edx || {}; }, saveError: function( error ) { + $(this.el).show(); // Show in case the form was hidden for auto-submission this.errors = _.flatten( _.map( JSON.parse(error.responseText), @@ -76,6 +88,13 @@ var edx = edx || {}; ); this.setErrors(); this.toggleDisableButton(false); - } + }, + + postFormSubmission: function() { + if (_.compact(this.errors).length) { + // The form did not get submitted due to validation errors. + $(this.el).show(); // Show in case the form was hidden for auto-submission + } + }, }); })(jQuery, _, gettext); diff --git a/lms/static/js/student_account/views/account_settings_factory.js b/lms/static/js/student_account/views/account_settings_factory.js index 07daa25174..1daf631906 100644 --- a/lms/static/js/student_account/views/account_settings_factory.js +++ b/lms/static/js/student_account/views/account_settings_factory.js @@ -137,7 +137,7 @@ screenReaderTitle: interpolate_text( gettext("Connect your {accountName} account"), {accountName: provider['name']} ), - valueAttribute: 'auth-' + provider.name.toLowerCase(), + valueAttribute: 'auth-' + provider.id, helpMessage: '', connected: provider.connected, connectUrl: provider.connect_url, diff --git a/lms/static/sass/multicourse/_account.scss b/lms/static/sass/multicourse/_account.scss index 8c50ae95c7..1eacb688e8 100644 --- a/lms/static/sass/multicourse/_account.scss +++ b/lms/static/sass/multicourse/_account.scss @@ -532,30 +532,30 @@ margin-right: 0; } - &.button-Google:hover, &.button-Google:focus { + &.button-oa2-google-oauth2:hover, &.button-oa2-google-oauth2:focus { background-color: #dd4b39; border: 1px solid #A5382B; } - &.button-Google:hover { + &.button-oa2-google-oauth2:hover { box-shadow: 0 2px 1px 0 #8D3024; } - &.button-Facebook:hover, &.button-Facebook:focus { + &.button-oa2-facebook:hover, &.button-oa2-facebook:focus { background-color: #3b5998; border: 1px solid #263A62; } - &.button-Facebook:hover { + &.button-oa2-facebook:hover { box-shadow: 0 2px 1px 0 #30487C; } - &.button-LinkedIn:hover , &.button-LinkedIn:focus { + &.button-oa2-linkedin-oauth2:hover , &.button-oa2-linkedin-oauth2:focus { background-color: #0077b5; border: 1px solid #06527D; } - &.button-LinkedIn:hover { + &.button-oa2-linkedin-oauth2:hover { box-shadow: 0 2px 1px 0 #005D8E; } diff --git a/lms/static/sass/views/_login-register.scss b/lms/static/sass/views/_login-register.scss index 81d43ee80d..aad4cab6f3 100644 --- a/lms/static/sass/views/_login-register.scss +++ b/lms/static/sass/views/_login-register.scss @@ -14,6 +14,7 @@ $sm-btn-linkedin: #0077b5; background: $white; min-height: 100%; width: 100%; + $third-party-button-height: ($baseline*1.75); h2 { @extend %t-title5; @@ -22,6 +23,10 @@ $sm-btn-linkedin: #0077b5; font-family: $sans-serif; } + .instructions { + @extend %t-copy-base; + } + /* Temp. fix until applied globally */ > { @include box-sizing(border-box); @@ -67,10 +72,11 @@ $sm-btn-linkedin: #0077b5; } } - form { + form, + .wrapper-other-login { border: 1px solid $gray-l4; - border-radius: 5px; - padding: 0px 25px 20px 25px; + border-radius: ($baseline/4); + padding: 0 ($baseline*1.25) $baseline ($baseline*1.25); } .section-title { @@ -106,16 +112,20 @@ $sm-btn-linkedin: #0077b5; } } - .nav-btn { + %nav-btn-base { @extend %btn-secondary-blue-outline; width: 100%; height: ($baseline*2); text-transform: none; text-shadow: none; - font-weight: 600; letter-spacing: normal; } + .nav-btn { + @extend %nav-btn-base; + @extend %t-strong; + } + .form-type, .toggle-form { @include box-sizing(border-box); @@ -348,29 +358,31 @@ $sm-btn-linkedin: #0077b5; .login-provider { @extend %btn-secondary-grey-outline; - width: 130px; - padding: 0 0 0 ($baseline*2); - height: 34px; - text-align: left; + @extend %t-action4; + + @include padding(0, 0, 0, $baseline*2); + @include text-align(left); + + position: relative; + margin-right: ($baseline/4); + margin-bottom: $baseline; + border-color: $lightGrey1; + width: $baseline*6.5; + height: $third-party-button-height; text-shadow: none; text-transform: none; - position: relative; - font-size: 0.8em; - border-color: $lightGrey1; - - &:nth-of-type(odd) { - margin-right: 13px; - } .icon { - color: white; + @include left(0); + position: absolute; top: -1px; - left: 0; width: 30px; - height: 34px; - line-height: 34px; + bottom: -1px; + background: $m-blue-d3; + line-height: $third-party-button-height; text-align: center; + color: $white; } &:hover, @@ -378,17 +390,13 @@ $sm-btn-linkedin: #0077b5; background-image: none; .icon { - height: 32px; - line-height: 32px; top: 0; + bottom: 0; + line-height: ($third-party-button-height - 2px); } } - &:last-child { - margin-bottom: $baseline; - } - - &.button-Google { + &.button-oa2-google-oauth2 { color: $sm-btn-google; .icon { @@ -407,7 +415,7 @@ $sm-btn-linkedin: #0077b5; } } - &.button-Facebook { + &.button-oa2-facebook { color: $sm-btn-facebook; .icon { @@ -426,7 +434,7 @@ $sm-btn-linkedin: #0077b5; } } - &.button-LinkedIn { + &.button-oa2-linkedin-oauth2 { color: $sm-btn-linkedin; .icon { @@ -447,6 +455,19 @@ $sm-btn-linkedin: #0077b5; } + .button-secondary-login { + @extend %nav-btn-base; + @extend %t-action4; + @extend %t-regular; + border-color: $lightGrey1; + padding: 0; + height: $third-party-button-height; + + &:hover { + border-color: $m-blue-d3; + } + } + /** Error Container - from _account.scss **/ .status { @include box-sizing(border-box); @@ -503,6 +524,13 @@ $sm-btn-linkedin: #0077b5; } } + .institution-list { + + .institution { + @extend %t-copy-base; + } + } + @include media( max-width 330px) { .form-type { width: 98%; diff --git a/lms/templates/dashboard/_dashboard_third_party_error.html b/lms/templates/dashboard/_dashboard_third_party_error.html index 99ba0ae4fb..a7958b9481 100644 --- a/lms/templates/dashboard/_dashboard_third_party_error.html +++ b/lms/templates/dashboard/_dashboard_third_party_error.html @@ -5,7 +5,7 @@

${_("Could Not Link Accounts")}

## Translators: this message is displayed when a user tries to link their account with a third-party authentication provider (for example, Google or LinkedIn) with a given edX account, but their third-party account is already associated with another edX account. provider_name is the name of the third-party authentication provider, and platform_name is the name of the edX deployment. -

${_("The {provider_name} account you selected is already linked to another {platform_name} account.").format(provider_name='{duplicate_provider}'.format(duplicate_provider=duplicate_provider.NAME), platform_name=platform_name)}

+

${_("The {provider_name} account you selected is already linked to another {platform_name} account.").format(provider_name=duplicate_provider, platform_name=platform_name)}

diff --git a/lms/templates/login.html b/lms/templates/login.html index 3280ca7696..c6483df2a0 100644 --- a/lms/templates/login.html +++ b/lms/templates/login.html @@ -221,7 +221,7 @@ from microsite_configuration import microsite % for enabled in provider.Registry.enabled(): ## Translators: provider_name is the name of an external, third-party user authentication provider (like Google or LinkedIn). - + % endfor diff --git a/lms/templates/register.html b/lms/templates/register.html index f885769658..c913be8466 100644 --- a/lms/templates/register.html +++ b/lms/templates/register.html @@ -132,7 +132,7 @@ import calendar % for enabled in provider.Registry.enabled(): ## Translators: provider_name is the name of an external, third-party user authentication service (like Google or LinkedIn). - + % endfor diff --git a/lms/templates/student_account/access.underscore b/lms/templates/student_account/access.underscore index 2eee3a2a3d..a2bc97030f 100644 --- a/lms/templates/student_account/access.underscore +++ b/lms/templates/student_account/access.underscore @@ -9,3 +9,11 @@
+ +
+ +
+ +
+
+
diff --git a/lms/templates/student_account/hinted_login.underscore b/lms/templates/student_account/hinted_login.underscore new file mode 100644 index 0000000000..d1cb0d8379 --- /dev/null +++ b/lms/templates/student_account/hinted_login.underscore @@ -0,0 +1,24 @@ + diff --git a/lms/templates/student_account/institution_login.underscore b/lms/templates/student_account/institution_login.underscore new file mode 100644 index 0000000000..88861616e2 --- /dev/null +++ b/lms/templates/student_account/institution_login.underscore @@ -0,0 +1,31 @@ + diff --git a/lms/templates/student_account/institution_register.underscore b/lms/templates/student_account/institution_register.underscore new file mode 100644 index 0000000000..ba97dd6e7e --- /dev/null +++ b/lms/templates/student_account/institution_register.underscore @@ -0,0 +1,31 @@ + diff --git a/lms/templates/student_account/login.underscore b/lms/templates/student_account/login.underscore index 5420e769c4..58447bdba8 100644 --- a/lms/templates/student_account/login.underscore +++ b/lms/templates/student_account/login.underscore @@ -39,7 +39,7 @@ - <% if ( context.providers.length > 0 && !context.currentProvider ) { %> + <% if ( context.providers.length > 0 && !context.currentProvider || context.hasSecondaryProviders ) { %> - <% } else if ( context.providers.length > 0 ) { %> + <% } else if ( context.providers.length > 0 || context.hasSecondaryProviders ) { %>