From 4d1194800eff392bf01026f218d8d803b4676656 Mon Sep 17 00:00:00 2001 From: William Ono Date: Thu, 28 May 2015 14:16:37 -0700 Subject: [PATCH 1/2] Support LTI third-party-auth providers PR #8930 --- common/djangoapps/student/helpers.py | 2 +- common/djangoapps/student/views.py | 9 +- common/djangoapps/third_party_auth/admin.py | 25 ++- common/djangoapps/third_party_auth/lti.py | 202 ++++++++++++++++++ .../migrations/0004_lti_tool_consumers.py | 149 +++++++++++++ common/djangoapps/third_party_auth/models.py | 73 +++++++ .../djangoapps/third_party_auth/pipeline.py | 7 +- .../djangoapps/third_party_auth/provider.py | 18 +- .../djangoapps/third_party_auth/strategy.py | 4 +- .../tests/data/lti_cannot_add_get_params.txt | 1 + .../tests/data/lti_garbage.txt | 1 + .../tests/data/lti_invalid_signature.txt | 1 + .../tests/data/lti_old_timestamp.txt | 1 + .../tests/data/lti_valid_request.txt | 1 + .../lti_valid_request_with_get_params.txt | 1 + .../third_party_auth/tests/specs/test_lti.py | 159 ++++++++++++++ .../tests/specs/test_testshib.py | 13 +- .../third_party_auth/tests/test_lti.py | 133 ++++++++++++ .../third_party_auth/tests/testutil.py | 39 +++- common/djangoapps/third_party_auth/urls.py | 3 +- common/djangoapps/third_party_auth/views.py | 20 +- lms/djangoapps/student_account/views.py | 17 +- lms/envs/aws.py | 2 + lms/envs/test.py | 1 + .../account_settings_factory_spec.js | 2 + .../account_settings_fields_spec.js | 1 + .../views/account_settings_factory.js | 1 + .../views/account_settings_fields.js | 11 +- lms/templates/login.html | 2 +- lms/templates/register.html | 2 +- .../student_profile/third_party_auth.html | 2 +- openedx/core/djangoapps/user_api/views.py | 39 ++-- 32 files changed, 879 insertions(+), 63 deletions(-) create mode 100644 common/djangoapps/third_party_auth/lti.py create mode 100644 common/djangoapps/third_party_auth/migrations/0004_lti_tool_consumers.py create mode 100644 common/djangoapps/third_party_auth/tests/data/lti_cannot_add_get_params.txt create mode 100644 common/djangoapps/third_party_auth/tests/data/lti_garbage.txt create mode 100644 common/djangoapps/third_party_auth/tests/data/lti_invalid_signature.txt create mode 100644 common/djangoapps/third_party_auth/tests/data/lti_old_timestamp.txt create mode 100644 common/djangoapps/third_party_auth/tests/data/lti_valid_request.txt create mode 100644 common/djangoapps/third_party_auth/tests/data/lti_valid_request_with_get_params.txt create mode 100644 common/djangoapps/third_party_auth/tests/specs/test_lti.py create mode 100644 common/djangoapps/third_party_auth/tests/test_lti.py diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index f5ce8a770a..4449ecaa6d 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -189,7 +189,7 @@ def auth_pipeline_urls(auth_entry, redirect_url=None): return { 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() + ) for provider in third_party_auth.provider.Registry.accepting_logins() } diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 07b7c5f37a..0f262df3cc 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -447,10 +447,11 @@ def register_user(request, extra_context=None): if third_party_auth.is_enabled() and pipeline.running(request): running_pipeline = pipeline.get(request) 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 - context.update(overrides) + if current_provider is not None: + overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs')) + overrides['running_pipeline'] = running_pipeline + overrides['selected_provider'] = current_provider.name + context.update(overrides) return render_to_response('register.html', context) diff --git a/common/djangoapps/third_party_auth/admin.py b/common/djangoapps/third_party_auth/admin.py index a949f3fcb0..6b2a9bfccb 100644 --- a/common/djangoapps/third_party_auth/admin.py +++ b/common/djangoapps/third_party_auth/admin.py @@ -6,7 +6,7 @@ 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 .models import OAuth2ProviderConfig, SAMLProviderConfig, SAMLConfiguration, SAMLProviderData, LTIProviderConfig from .tasks import fetch_saml_metadata @@ -88,3 +88,26 @@ class SAMLProviderDataAdmin(admin.ModelAdmin): return self.readonly_fields admin.site.register(SAMLProviderData, SAMLProviderDataAdmin) + + +class LTIProviderConfigAdmin(KeyedConfigurationModelAdmin): + """ Django Admin class for LTIProviderConfig """ + + exclude = ( + 'icon_class', + 'secondary', + ) + + def get_list_display(self, request): + """ Don't show every single field in the admin change list """ + return ( + 'name', + 'enabled', + 'lti_consumer_key', + 'lti_max_timestamp_age', + 'change_date', + 'changed_by', + 'edit_link', + ) + +admin.site.register(LTIProviderConfig, LTIProviderConfigAdmin) diff --git a/common/djangoapps/third_party_auth/lti.py b/common/djangoapps/third_party_auth/lti.py new file mode 100644 index 0000000000..222f76a388 --- /dev/null +++ b/common/djangoapps/third_party_auth/lti.py @@ -0,0 +1,202 @@ +""" +Third-party-auth module for Learning Tools Interoperability +""" +import logging +import calendar +import time + +from django.contrib.auth import REDIRECT_FIELD_NAME +from oauthlib.common import Request +from oauthlib.oauth1.rfc5849.signature import ( + normalize_base_string_uri, + normalize_parameters, + collect_parameters, + construct_base_string, + sign_hmac_sha1, +) +from social.backends.base import BaseAuth +from social.exceptions import AuthFailed +from social.utils import sanitize_redirect + +log = logging.getLogger(__name__) + +LTI_PARAMS_KEY = 'tpa-lti-params' + + +class LTIAuthBackend(BaseAuth): + """ + Third-party-auth module for Learning Tools Interoperability + """ + + name = 'lti' + + def start(self): + """ + Prepare to handle a login request. + + This method replaces social.actions.do_auth and must be kept in sync + with any upstream changes in that method. In the current version of + the upstream, this means replacing the logic to populate the session + from request parameters, and not calling backend.start() to avoid + an unwanted redirect to the non-existent login page. + """ + + # Clean any partial pipeline data + self.strategy.clean_partial_pipeline() + + # Save validated LTI parameters (or None if invalid or not submitted) + validated_lti_params = self.get_validated_lti_params(self.strategy) + + # Set a auth_entry here so we don't have to receive that as a custom parameter + self.strategy.session_setdefault('auth_entry', 'login') + + if not validated_lti_params: + self.strategy.session_set(LTI_PARAMS_KEY, None) + raise AuthFailed(self, "LTI parameters could not be validated.") + else: + self.strategy.session_set(LTI_PARAMS_KEY, validated_lti_params) + + # Save extra data into session. + # While Basic LTI 1.0 specifies that the message is to be signed using OAuth, implying + # that any GET parameters should be stripped from the base URL and included as signed + # parameters, typical LTI Tool Consumer implementations do not support this behaviour. As + # a workaround, we accept TPA parameters from LTI custom parameters prefixed with "tpa_". + + for field_name in self.setting('FIELDS_STORED_IN_SESSION', []): + if 'custom_tpa_' + field_name in validated_lti_params: + self.strategy.session_set(field_name, validated_lti_params['custom_tpa_' + field_name]) + + if 'custom_tpa_' + REDIRECT_FIELD_NAME in validated_lti_params: + # Check and sanitize a user-defined GET/POST next field value + redirect_uri = validated_lti_params['custom_tpa_' + REDIRECT_FIELD_NAME] + if self.setting('SANITIZE_REDIRECTS', True): + redirect_uri = sanitize_redirect(self.strategy.request_host(), redirect_uri) + self.strategy.session_set(REDIRECT_FIELD_NAME, redirect_uri or self.setting('LOGIN_REDIRECT_URL')) + + def auth_html(self): + """ + Not used + """ + raise NotImplementedError("Not used") + + def auth_url(self): + """ + Not used + """ + raise NotImplementedError("Not used") + + def auth_complete(self, *args, **kwargs): + """ + Completes third-part-auth authentication + """ + lti_params = self.strategy.session_get(LTI_PARAMS_KEY) + kwargs.update({'response': {LTI_PARAMS_KEY: lti_params}, 'backend': self}) + return self.strategy.authenticate(*args, **kwargs) + + def get_user_id(self, details, response): + """ + Computes social auth username from LTI parameters + """ + lti_params = response[LTI_PARAMS_KEY] + return lti_params['oauth_consumer_key'] + ":" + lti_params['user_id'] + + def get_user_details(self, response): + """ + Retrieves user details from LTI parameters + """ + details = {} + lti_params = response[LTI_PARAMS_KEY] + + def add_if_exists(lti_key, details_key): + """ + Adds LTI parameter to user details dict if it exists + """ + if lti_key in lti_params and lti_params[lti_key]: + details[details_key] = lti_params[lti_key] + + add_if_exists('email', 'email') + add_if_exists('lis_person_name_full', 'fullname') + add_if_exists('lis_person_name_given', 'first_name') + add_if_exists('lis_person_name_family', 'last_name') + return details + + @classmethod + def get_validated_lti_params(cls, strategy): + """ + Validates LTI signature and returns LTI parameters + """ + request = Request( + uri=strategy.request.build_absolute_uri(), http_method=strategy.request.method, body=strategy.request.body + ) + lti_consumer_key = request.oauth_consumer_key + (lti_consumer_valid, lti_consumer_secret, lti_max_timestamp_age) = cls.load_lti_consumer(lti_consumer_key) + current_time = calendar.timegm(time.gmtime()) + return cls._get_validated_lti_params_from_values( + request=request, current_time=current_time, + lti_consumer_valid=lti_consumer_valid, + lti_consumer_secret=lti_consumer_secret, + lti_max_timestamp_age=lti_max_timestamp_age + ) + + @classmethod + def _get_validated_lti_params_from_values(cls, request, current_time, + lti_consumer_valid, lti_consumer_secret, lti_max_timestamp_age): + """ + Validates LTI signature and returns LTI parameters + """ + + # Taking a cue from oauthlib, to avoid leaking information through a timing attack, + # we proceed through the entire validation before rejecting any request for any reason. + # However, as noted there, the value of doing this is dubious. + + base_uri = normalize_base_string_uri(request.uri) + parameters = collect_parameters(uri_query=request.uri_query, body=request.body) + parameters_string = normalize_parameters(parameters) + base_string = construct_base_string(request.http_method, base_uri, parameters_string) + + computed_signature = sign_hmac_sha1(base_string, unicode(lti_consumer_secret), '') + submitted_signature = request.oauth_signature + + data = {parameter_value_pair[0]: parameter_value_pair[1] for parameter_value_pair in parameters} + + def safe_int(value): + """ + Interprets parameter as an int or returns 0 if not possible + """ + try: + return int(value) + except (ValueError, TypeError): + return 0 + + oauth_timestamp = safe_int(request.oauth_timestamp) + + # As this must take constant time, do not use shortcutting operators such as 'and'. + # Instead, use constant time operators such as '&', which is the bitwise and. + valid = (lti_consumer_valid) + valid = valid & (submitted_signature == computed_signature) + valid = valid & (request.oauth_version == '1.0') + valid = valid & (request.oauth_signature_method == 'HMAC-SHA1') + valid = valid & ('user_id' in data) # Not required by LTI but can't log in without one + valid = valid & (oauth_timestamp >= current_time - lti_max_timestamp_age) + valid = valid & (oauth_timestamp <= current_time) + + if valid: + return data + else: + return None + + @classmethod + def load_lti_consumer(cls, lti_consumer_key): + """ + Retrieves LTI consumer details from database + """ + from .models import LTIProviderConfig + provider_config = LTIProviderConfig.current(lti_consumer_key) + if provider_config and provider_config.enabled: + return ( + provider_config.enabled, + provider_config.get_lti_consumer_secret(), + provider_config.lti_max_timestamp_age, + ) + else: + return False, '', -1 diff --git a/common/djangoapps/third_party_auth/migrations/0004_lti_tool_consumers.py b/common/djangoapps/third_party_auth/migrations/0004_lti_tool_consumers.py new file mode 100644 index 0000000000..dfef06ae73 --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0004_lti_tool_consumers.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +# pylint: disable=C,E,F,R,W +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 'LTIProviderConfig' + db.create_table('third_party_auth_ltiproviderconfig', ( + ('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)), + ('secondary', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('skip_registration_form', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('skip_email_verification', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('lti_consumer_key', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('lti_consumer_secret', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('lti_max_timestamp_age', self.gf('django.db.models.fields.IntegerField')(default=10)), + )) + db.send_create_signal('third_party_auth', ['LTIProviderConfig']) + + + def backwards(self, orm): + # Deleting model 'LTIProviderConfig' + db.delete_table('third_party_auth_ltiproviderconfig') + + + 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.ltiproviderconfig': { + 'Meta': {'object_name': 'LTIProviderConfig'}, + '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'}), + 'lti_consumer_key': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'lti_consumer_secret': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'lti_max_timestamp_age': ('django.db.models.fields.IntegerField', [], {'default': '10'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + '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.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'] diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index 79ba5746bf..253e1e610b 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -3,6 +3,8 @@ Models used to implement SAML SSO support in third_party_auth (inlcuding Shibboleth support) """ +from __future__ import absolute_import + from config_models.models import ConfigurationModel, cache from django.conf import settings from django.core.exceptions import ValidationError @@ -11,9 +13,11 @@ from django.utils import timezone from django.utils.translation import ugettext_lazy as _ import json import logging +from provider.utils import long_token from social.backends.base import BaseAuth from social.backends.oauth import OAuthAuth from social.backends.saml import SAMLAuth, SAMLIdentityProvider +from .lti import LTIAuthBackend, LTI_PARAMS_KEY from social.exceptions import SocialAuthBaseException from social.utils import module_member @@ -32,6 +36,7 @@ def _load_backend_classes(base_class=BaseAuth): _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(OAuthAuth)] _PSA_SAML_BACKENDS = [backend_class.name for backend_class in _load_backend_classes(SAMLAuth)] +_LTI_BACKENDS = [backend_class.name for backend_class in _load_backend_classes(LTIAuthBackend)] def clean_json(value, of_type): @@ -95,6 +100,7 @@ class ProviderConfig(ConfigurationModel): ) 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 + accepts_logins = True # Whether to display a sign-in button when the provider is enabled # "enabled" field is inherited from ConfigurationModel @@ -454,3 +460,70 @@ class SAMLProviderData(models.Model): cache.set(cls.cache_key_name(entity_id), current, cls.cache_timeout) return current + + +class LTIProviderConfig(ProviderConfig): + """ + Configuration required for this edX instance to act as a LTI + Tool Provider and allow users to authenticate and be enrolled in a + course via third party LTI Tool Consumers. + """ + prefix = 'lti' + backend_name = 'lti' + icon_class = None # This provider is not visible to users + secondary = False # This provider is not visible to users + accepts_logins = False # LTI login cannot be initiated by the tool provider + KEY_FIELDS = ('lti_consumer_key', ) + + lti_consumer_key = models.CharField( + max_length=255, + help_text=( + 'The name that the LTI Tool Consumer will use to identify itself' + ) + ) + lti_consumer_secret = models.CharField( + default=long_token, + max_length=255, + help_text=( + 'The shared secret that the LTI Tool Consumer will use to ' + 'authenticate requests. Only this edX instance and this ' + 'tool consumer instance should know this value. ' + 'For increased security, you can avoid storing this in ' + 'your database by leaving this field blank and setting ' + 'SOCIAL_AUTH_LTI_CONSUMER_SECRETS = {"consumer key": "secret", ...} ' + 'in your instance\'s Django setttigs (or lms.auth.json)' + ), + blank=True, + ) + + lti_max_timestamp_age = models.IntegerField( + default=10, + help_text=( + 'The maximum age of oauth_timestamp values, in seconds.' + ) + ) + + def match_social_auth(self, social_auth): + """ Is this provider being used for this UserSocialAuth entry? """ + prefix = self.lti_consumer_key + ":" + return self.backend_name == social_auth.provider and social_auth.uid.startswith(prefix) + + def is_active_for_pipeline(self, pipeline): + """ Is this provider being used for the specified pipeline? """ + try: + return ( + self.backend_name == pipeline['backend'] and + self.lti_consumer_key == pipeline['kwargs']['response'][LTI_PARAMS_KEY]['oauth_consumer_key'] + ) + except KeyError: + return False + + def get_lti_consumer_secret(self): + """ If the LTI consumer secret is not stored in the database, check Django settings instead """ + if self.lti_consumer_secret: + return self.lti_consumer_secret + return getattr(settings, 'SOCIAL_AUTH_LTI_CONSUMER_SECRETS', {}).get(self.lti_consumer_key, '') + + class Meta(object): # pylint: disable=missing-docstring + verbose_name = "Provider Configuration (LTI)" + verbose_name_plural = verbose_name diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index bf522e3c48..ba7da7ebec 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -372,9 +372,10 @@ def get_provider_user_states(user): if enabled_provider.match_social_auth(auth): association_id = auth.id break - states.append( - ProviderUserState(enabled_provider, user, association_id) - ) + if enabled_provider.accepts_logins or association_id: + states.append( + ProviderUserState(enabled_provider, user, association_id) + ) return states diff --git a/common/djangoapps/third_party_auth/provider.py b/common/djangoapps/third_party_auth/provider.py index 415e670900..dde2b41608 100644 --- a/common/djangoapps/third_party_auth/provider.py +++ b/common/djangoapps/third_party_auth/provider.py @@ -2,8 +2,8 @@ Third-party auth provider configuration API. """ from .models import ( - OAuth2ProviderConfig, SAMLConfiguration, SAMLProviderConfig, - _PSA_OAUTH2_BACKENDS, _PSA_SAML_BACKENDS + OAuth2ProviderConfig, SAMLConfiguration, SAMLProviderConfig, LTIProviderConfig, + _PSA_OAUTH2_BACKENDS, _PSA_SAML_BACKENDS, _LTI_BACKENDS, ) @@ -26,12 +26,21 @@ class Registry(object): provider = SAMLProviderConfig.current(idp_slug) if provider.enabled and provider.backend_name in _PSA_SAML_BACKENDS: yield provider + for consumer_key in LTIProviderConfig.key_values('lti_consumer_key', flat=True): + provider = LTIProviderConfig.current(consumer_key) + if provider.enabled and provider.backend_name in _LTI_BACKENDS: + yield provider @classmethod def enabled(cls): """Returns list of enabled providers.""" return sorted(cls._enabled_providers(), key=lambda provider: provider.name) + @classmethod + def accepting_logins(cls): + """Returns list of providers that can be used to initiate logins currently""" + return [provider for provider in cls.enabled() if provider.accepts_logins] + @classmethod def get(cls, provider_id): """Gets provider by provider_id string if enabled, else None.""" @@ -83,3 +92,8 @@ class Registry(object): provider = SAMLProviderConfig.current(idp_name) if provider.backend_name == backend_name and provider.enabled: yield provider + elif backend_name in _LTI_BACKENDS: + for consumer_key in LTIProviderConfig.key_values('lti_consumer_key', flat=True): + provider = LTIProviderConfig.current(consumer_key) + if provider.backend_name == backend_name and provider.enabled: + yield provider diff --git a/common/djangoapps/third_party_auth/strategy.py b/common/djangoapps/third_party_auth/strategy.py index eeff362ff1..fbb6c765b3 100644 --- a/common/djangoapps/third_party_auth/strategy.py +++ b/common/djangoapps/third_party_auth/strategy.py @@ -20,6 +20,8 @@ class ConfigurationModelStrategy(DjangoStrategy): OAuthAuth 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. + LTIAuthBackend subclasses will call this method only after first checking if the + setting 'name' is configured via LTIProviderConfig. """ if isinstance(backend, OAuthAuth): provider_config = OAuth2ProviderConfig.current(backend.name) @@ -29,6 +31,6 @@ class ConfigurationModelStrategy(DjangoStrategy): return provider_config.get_setting(name) except KeyError: pass - # At this point, we know 'name' is not set in a [OAuth2|SAML]ProviderConfig row. + # At this point, we know 'name' is not set in a [OAuth2|LTI|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/tests/data/lti_cannot_add_get_params.txt b/common/djangoapps/third_party_auth/tests/data/lti_cannot_add_get_params.txt new file mode 100644 index 0000000000..3117b3163c --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/data/lti_cannot_add_get_params.txt @@ -0,0 +1 @@ +lti_message_type=basic-lti-launch-request<i_version=LTI-1p0&lis_outcome_service_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Fcommon%2Ftool_consumer_outcome.php%3Fb64%3DMTIzNDU6OjpzZWNyZXQ%3D&lis_result_sourcedid=feb-123-456-2929%3A%3A28883&launch_presentation_return_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Flms_return.php&user_id=292832126&custom_extra=parameter&oauth_version=1.0&oauth_nonce=c4936a7122f4f85c2d95afe32391573b&oauth_timestamp=1436823553&oauth_consumer_key=12345&oauth_signature_method=HMAC-SHA1&oauth_signature=STPWUouDw%2FlRGD4giWf8lpGTc54%3D&oauth_callback=about%3Ablank \ No newline at end of file diff --git a/common/djangoapps/third_party_auth/tests/data/lti_garbage.txt b/common/djangoapps/third_party_auth/tests/data/lti_garbage.txt new file mode 100644 index 0000000000..791be54782 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/data/lti_garbage.txt @@ -0,0 +1 @@ +some=garbage&values=provided \ No newline at end of file diff --git a/common/djangoapps/third_party_auth/tests/data/lti_invalid_signature.txt b/common/djangoapps/third_party_auth/tests/data/lti_invalid_signature.txt new file mode 100644 index 0000000000..9cac2ecbd1 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/data/lti_invalid_signature.txt @@ -0,0 +1 @@ +lti_message_type=basic-lti-launch-request<i_version=LTI-1p0&lis_outcome_service_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Fcommon%2Ftool_consumer_outcome.php%3Fb64%3DMTIzNDU6OjpzZWNyZXQ%3D&lis_result_sourcedid=feb-123-456-2929%3A%3A28883&launch_presentation_return_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Flms_return.php&user_id=292832126&custom_extra=parameter&oauth_version=1.0&oauth_nonce=c4936a7122f4f85c2d95afe32391573b&oauth_timestamp=1436823553&oauth_consumer_key=12345&oauth_signature_method=HMAC-SHA1&oauth_signature=STPWUouDw%2FlRGD4giWf8lpXXXXX%3D&oauth_callback=about%3Ablank \ No newline at end of file diff --git a/common/djangoapps/third_party_auth/tests/data/lti_old_timestamp.txt b/common/djangoapps/third_party_auth/tests/data/lti_old_timestamp.txt new file mode 100644 index 0000000000..3117b3163c --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/data/lti_old_timestamp.txt @@ -0,0 +1 @@ +lti_message_type=basic-lti-launch-request<i_version=LTI-1p0&lis_outcome_service_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Fcommon%2Ftool_consumer_outcome.php%3Fb64%3DMTIzNDU6OjpzZWNyZXQ%3D&lis_result_sourcedid=feb-123-456-2929%3A%3A28883&launch_presentation_return_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Flms_return.php&user_id=292832126&custom_extra=parameter&oauth_version=1.0&oauth_nonce=c4936a7122f4f85c2d95afe32391573b&oauth_timestamp=1436823553&oauth_consumer_key=12345&oauth_signature_method=HMAC-SHA1&oauth_signature=STPWUouDw%2FlRGD4giWf8lpGTc54%3D&oauth_callback=about%3Ablank \ No newline at end of file diff --git a/common/djangoapps/third_party_auth/tests/data/lti_valid_request.txt b/common/djangoapps/third_party_auth/tests/data/lti_valid_request.txt new file mode 100644 index 0000000000..3117b3163c --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/data/lti_valid_request.txt @@ -0,0 +1 @@ +lti_message_type=basic-lti-launch-request<i_version=LTI-1p0&lis_outcome_service_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Fcommon%2Ftool_consumer_outcome.php%3Fb64%3DMTIzNDU6OjpzZWNyZXQ%3D&lis_result_sourcedid=feb-123-456-2929%3A%3A28883&launch_presentation_return_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Flms_return.php&user_id=292832126&custom_extra=parameter&oauth_version=1.0&oauth_nonce=c4936a7122f4f85c2d95afe32391573b&oauth_timestamp=1436823553&oauth_consumer_key=12345&oauth_signature_method=HMAC-SHA1&oauth_signature=STPWUouDw%2FlRGD4giWf8lpGTc54%3D&oauth_callback=about%3Ablank \ No newline at end of file diff --git a/common/djangoapps/third_party_auth/tests/data/lti_valid_request_with_get_params.txt b/common/djangoapps/third_party_auth/tests/data/lti_valid_request_with_get_params.txt new file mode 100644 index 0000000000..673dadbfc4 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/data/lti_valid_request_with_get_params.txt @@ -0,0 +1 @@ +lti_message_type=basic-lti-launch-request&lis_outcome_service_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Fcommon%2Ftool_consumer_outcome.php%3Fb64%3DMTIzNDU6OjpzZWNyZXQ%3D&lis_result_sourcedid=feb-123-456-2929%3A%3A28883&launch_presentation_return_url=http%3A%2F%2Fwww.imsglobal.org%2Fdevelopers%2FLTI%2Ftest%2Fv1p1%2Flms_return.php&custom_extra=parameter&oauth_version=1.0&oauth_nonce=c4936a7122f4f85c2d95afe32391573b&oauth_timestamp=1436823553&oauth_consumer_key=12345&oauth_signature_method=HMAC-SHA1&oauth_signature=STPWUouDw%2FlRGD4giWf8lpGTc54%3D&oauth_callback=about%3Ablank \ No newline at end of file diff --git a/common/djangoapps/third_party_auth/tests/specs/test_lti.py b/common/djangoapps/third_party_auth/tests/specs/test_lti.py new file mode 100644 index 0000000000..fa9e2398e4 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/specs/test_lti.py @@ -0,0 +1,159 @@ +""" +Integration tests for third_party_auth LTI auth providers +""" +import unittest +from django.conf import settings +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from oauthlib.oauth1.rfc5849 import Client, SIGNATURE_TYPE_BODY +from third_party_auth.tests import testutil + +FORM_ENCODED = 'application/x-www-form-urlencoded' + +LTI_CONSUMER_KEY = 'consumer' +LTI_CONSUMER_SECRET = 'secret' +LTI_TPA_LOGIN_URL = 'http://testserver/auth/login/lti/' +LTI_TPA_COMPLETE_URL = 'http://testserver/auth/complete/lti/' +OTHER_LTI_CONSUMER_KEY = 'settings-consumer' +OTHER_LTI_CONSUMER_SECRET = 'secret2' +LTI_USER_ID = 'lti_user_id' +EDX_USER_ID = 'test_user' +EMAIL = 'lti_user@example.com' + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class IntegrationTestLTI(testutil.TestCase): + """ + Integration tests for third_party_auth LTI auth providers + """ + + def setUp(self): + super(IntegrationTestLTI, self).setUp() + self.configure_lti_provider( + name='Other Tool Consumer 1', enabled=True, + lti_consumer_key='other1', + lti_consumer_secret='secret1', + lti_max_timestamp_age=10, + ) + self.configure_lti_provider( + name='LTI Test Tool Consumer', enabled=True, + lti_consumer_key=LTI_CONSUMER_KEY, + lti_consumer_secret=LTI_CONSUMER_SECRET, + lti_max_timestamp_age=10, + ) + self.configure_lti_provider( + name='Tool Consumer with Secret in Settings', enabled=True, + lti_consumer_key=OTHER_LTI_CONSUMER_KEY, + lti_consumer_secret='', + lti_max_timestamp_age=10, + ) + self.lti = Client( + client_key=LTI_CONSUMER_KEY, + client_secret=LTI_CONSUMER_SECRET, + signature_type=SIGNATURE_TYPE_BODY, + ) + + def test_lti_login(self): + # The user initiates a login from an external site + (uri, _headers, body) = self.lti.sign( + uri=LTI_TPA_LOGIN_URL, http_method='POST', + headers={'Content-Type': FORM_ENCODED}, + body={ + 'user_id': LTI_USER_ID, + 'custom_tpa_next': '/account/finish_auth/?course_id=my_course_id&enrollment_action=enroll', + } + ) + login_response = self.client.post(path=uri, content_type=FORM_ENCODED, data=body) + # The user should be redirected to the registration form + self.assertEqual(login_response.status_code, 302) + self.assertTrue(login_response['Location'].endswith(reverse('signin_user'))) + register_response = self.client.get(login_response['Location']) + self.assertEqual(register_response.status_code, 200) + self.assertIn('currentProvider": "LTI Test Tool Consumer"', register_response.content) + self.assertIn('"errorMessage": null', register_response.content) + + # Now complete the form: + ajax_register_response = self.client.post( + reverse('user_api_registration'), + { + 'email': EMAIL, + 'name': 'Myself', + 'username': EDX_USER_ID, + 'honor_code': True, + } + ) + self.assertEqual(ajax_register_response.status_code, 200) + continue_response = self.client.get(LTI_TPA_COMPLETE_URL) + # The user should be redirected to the finish_auth view which will enroll them. + # FinishAuthView.js reads the URL parameters directly from $.url + self.assertEqual(continue_response.status_code, 302) + self.assertEqual( + continue_response['Location'], + 'http://testserver/account/finish_auth/?course_id=my_course_id&enrollment_action=enroll' + ) + + # Now check that we can login again + self.client.logout() + self.verify_user_email(EMAIL) + (uri, _headers, body) = self.lti.sign( + uri=LTI_TPA_LOGIN_URL, http_method='POST', + headers={'Content-Type': FORM_ENCODED}, + body={'user_id': LTI_USER_ID} + ) + login_2_response = self.client.post(path=uri, content_type=FORM_ENCODED, data=body) + # The user should be redirected to the dashboard + self.assertEqual(login_2_response.status_code, 302) + self.assertEqual(login_2_response['Location'], LTI_TPA_COMPLETE_URL) + continue_2_response = self.client.get(login_2_response['Location']) + self.assertEqual(continue_2_response.status_code, 302) + self.assertTrue(continue_2_response['Location'].endswith(reverse('dashboard'))) + + # Check that the user was created correctly + user = User.objects.get(email=EMAIL) + self.assertEqual(user.username, EDX_USER_ID) + + def test_reject_initiating_login(self): + response = self.client.get(LTI_TPA_LOGIN_URL) + self.assertEqual(response.status_code, 405) # Not Allowed + + def test_reject_bad_login(self): + login_response = self.client.post( + path=LTI_TPA_LOGIN_URL, content_type=FORM_ENCODED, + data="invalid=login" + ) + # The user should be redirected to the login page with an error message + # (auth_entry defaults to login for this provider) + self.assertEqual(login_response.status_code, 302) + self.assertTrue(login_response['Location'].endswith(reverse('signin_user'))) + error_response = self.client.get(login_response['Location']) + self.assertIn( + 'Authentication failed: LTI parameters could not be validated.', + error_response.content + ) + + def test_can_load_consumer_secret_from_settings(self): + lti = Client( + client_key=OTHER_LTI_CONSUMER_KEY, + client_secret=OTHER_LTI_CONSUMER_SECRET, + signature_type=SIGNATURE_TYPE_BODY, + ) + (uri, _headers, body) = lti.sign( + uri=LTI_TPA_LOGIN_URL, http_method='POST', + headers={'Content-Type': FORM_ENCODED}, + body={ + 'user_id': LTI_USER_ID, + 'custom_tpa_next': '/account/finish_auth/?course_id=my_course_id&enrollment_action=enroll', + } + ) + with self.settings(SOCIAL_AUTH_LTI_CONSUMER_SECRETS={OTHER_LTI_CONSUMER_KEY: OTHER_LTI_CONSUMER_SECRET}): + login_response = self.client.post(path=uri, content_type=FORM_ENCODED, data=body) + # The user should be redirected to the registration form + self.assertEqual(login_response.status_code, 302) + self.assertTrue(login_response['Location'].endswith(reverse('signin_user'))) + register_response = self.client.get(login_response['Location']) + self.assertEqual(register_response.status_code, 200) + self.assertIn( + 'currentProvider": "Tool Consumer with Secret in Settings"', + register_response.content + ) + self.assertIn('"errorMessage": null', register_response.content) diff --git a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py index aacb945aa6..dc4ca99880 100644 --- a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py +++ b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py @@ -1,7 +1,6 @@ """ 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 @@ -38,7 +37,7 @@ class TestShibIntegrationTest(testutil.SAMLTestCase): 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')) + 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) @@ -106,7 +105,7 @@ class TestShibIntegrationTest(testutil.SAMLTestCase): # Now check that we can login again: self.client.logout() - self._verify_user_email('myself@testshib.org') + self.verify_user_email('myself@testshib.org') self._test_return_login() def test_login(self): @@ -220,11 +219,5 @@ class TestShibIntegrationTest(testutil.SAMLTestCase): return self.client.post( TPA_TESTSHIB_COMPLETE_URL, content_type='application/x-www-form-urlencoded', - data=self._read_data_file('testshib_response.txt'), + 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_lti.py b/common/djangoapps/third_party_auth/tests/test_lti.py new file mode 100644 index 0000000000..9e0f9122c3 --- /dev/null +++ b/common/djangoapps/third_party_auth/tests/test_lti.py @@ -0,0 +1,133 @@ +""" +Unit tests for third_party_auth LTI auth providers +""" + +import unittest +from oauthlib.common import Request +from third_party_auth.lti import LTIAuthBackend, LTI_PARAMS_KEY +from third_party_auth.tests.testutil import ThirdPartyAuthTestMixin + + +class UnitTestLTI(unittest.TestCase, ThirdPartyAuthTestMixin): + """ + Unit tests for third_party_auth LTI auth providers + """ + + def test_get_user_details_missing_keys(self): + lti = LTIAuthBackend() + details = lti.get_user_details({LTI_PARAMS_KEY: { + 'lis_person_name_full': 'Full name' + }}) + self.assertEquals(details, { + 'fullname': 'Full name' + }) + + def test_get_user_details_extra_keys(self): + lti = LTIAuthBackend() + details = lti.get_user_details({LTI_PARAMS_KEY: { + 'lis_person_name_full': 'Full name', + 'lis_person_name_given': 'Given', + 'lis_person_name_family': 'Family', + 'email': 'user@example.com', + 'other': 'something else' + }}) + self.assertEquals(details, { + 'fullname': 'Full name', + 'first_name': 'Given', + 'last_name': 'Family', + 'email': 'user@example.com' + }) + + def test_get_user_id(self): + lti = LTIAuthBackend() + user_id = lti.get_user_id(None, {LTI_PARAMS_KEY: { + 'oauth_consumer_key': 'consumer', + 'user_id': 'user' + }}) + self.assertEquals(user_id, 'consumer:user') + + def test_validate_lti_valid_request(self): + request = Request( + uri='https://example.com/lti', + http_method='POST', + body=self.read_data_file('lti_valid_request.txt') + ) + parameters = LTIAuthBackend._get_validated_lti_params_from_values( # pylint: disable=protected-access + request=request, current_time=1436823554, + lti_consumer_valid=True, lti_consumer_secret='secret', + lti_max_timestamp_age=10 + ) + self.assertTrue(parameters) + self.assertDictContainsSubset({ + 'custom_extra': 'parameter', + 'user_id': '292832126' + }, parameters) + + def test_validate_lti_valid_request_with_get_params(self): + request = Request( + uri='https://example.com/lti?user_id=292832126<i_version=LTI-1p0', + http_method='POST', + body=self.read_data_file('lti_valid_request_with_get_params.txt') + ) + parameters = LTIAuthBackend._get_validated_lti_params_from_values( # pylint: disable=protected-access + request=request, current_time=1436823554, + lti_consumer_valid=True, lti_consumer_secret='secret', + lti_max_timestamp_age=10 + ) + self.assertTrue(parameters) + self.assertDictContainsSubset({ + 'custom_extra': 'parameter', + 'user_id': '292832126' + }, parameters) + + def test_validate_lti_old_timestamp(self): + request = Request( + uri='https://example.com/lti', + http_method='POST', + body=self.read_data_file('lti_old_timestamp.txt') + ) + parameters = LTIAuthBackend._get_validated_lti_params_from_values( # pylint: disable=protected-access + request=request, current_time=1436900000, + lti_consumer_valid=True, lti_consumer_secret='secret', + lti_max_timestamp_age=10 + ) + self.assertFalse(parameters) + + def test_validate_lti_invalid_signature(self): + request = Request( + uri='https://example.com/lti', + http_method='POST', + body=self.read_data_file('lti_invalid_signature.txt') + ) + parameters = LTIAuthBackend._get_validated_lti_params_from_values( # pylint: disable=protected-access + request=request, current_time=1436823554, + lti_consumer_valid=True, lti_consumer_secret='secret', + lti_max_timestamp_age=10 + ) + self.assertFalse(parameters) + + def test_validate_lti_cannot_add_get_params(self): + request = Request( + uri='https://example.com/lti?custom_another=parameter', + http_method='POST', + body=self.read_data_file('lti_cannot_add_get_params.txt') + ) + parameters = LTIAuthBackend._get_validated_lti_params_from_values( # pylint: disable=protected-access + request=request, current_time=1436823554, + lti_consumer_valid=True, lti_consumer_secret='secret', + lti_max_timestamp_age=10 + ) + self.assertFalse(parameters) + + def test_validate_lti_garbage(self): + request = Request( + uri='https://example.com/lti', + http_method='POST', + body=self.read_data_file('lti_garbage.txt') + ) + parameters = LTIAuthBackend._get_validated_lti_params_from_values( # pylint: disable=protected-access + request=request, current_time=1436823554, + lti_consumer_valid=True, lti_consumer_secret='secret', + lti_max_timestamp_age=10 + ) + self.assertFalse(parameters) diff --git a/common/djangoapps/third_party_auth/tests/testutil.py b/common/djangoapps/third_party_auth/tests/testutil.py index 12022d2bf7..40591163db 100644 --- a/common/djangoapps/third_party_auth/tests/testutil.py +++ b/common/djangoapps/third_party_auth/tests/testutil.py @@ -6,11 +6,18 @@ Used by Django and non-Django tests; must not have Django deps. from contextlib import contextmanager from django.conf import settings +from django.contrib.auth.models import User import django.test import mock import os.path -from third_party_auth.models import OAuth2ProviderConfig, SAMLProviderConfig, SAMLConfiguration, cache as config_cache +from third_party_auth.models import ( + OAuth2ProviderConfig, + SAMLProviderConfig, + SAMLConfiguration, + LTIProviderConfig, + cache as config_cache, +) AUTH_FEATURES_KEY = 'ENABLE_THIRD_PARTY_AUTH' @@ -52,6 +59,13 @@ class ThirdPartyAuthTestMixin(object): obj.save() return obj + @staticmethod + def configure_lti_provider(**kwargs): + """ Update the settings for a LTI Tool Consumer third party auth provider """ + obj = LTIProviderConfig(**kwargs) + obj.save() + return obj + @classmethod def configure_google_provider(cls, **kwargs): """ Update the settings for the Google third party auth provider/backend """ @@ -92,6 +106,19 @@ class ThirdPartyAuthTestMixin(object): kwargs.setdefault("secret", "test") return cls.configure_oauth_provider(**kwargs) + @classmethod + def verify_user_email(cls, email): + """ Mark the user with the given email as verified """ + user = User.objects.get(email=email) + user.is_active = True + user.save() + + @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() + class TestCase(ThirdPartyAuthTestMixin, django.test.TestCase): """Base class for auth test cases.""" @@ -111,18 +138,12 @@ class SAMLTestCase(TestCase): @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)) + 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() + return cls.read_data_file('{}.key'.format(key_name)) def enable_saml(self, **kwargs): """ Enable SAML support (via SAMLConfiguration, not for any particular provider) """ diff --git a/common/djangoapps/third_party_auth/urls.py b/common/djangoapps/third_party_auth/urls.py index 5d366b2da3..69c600932b 100644 --- a/common/djangoapps/third_party_auth/urls.py +++ b/common/djangoapps/third_party_auth/urls.py @@ -2,11 +2,12 @@ from django.conf.urls import include, patterns, url -from .views import inactive_user_view, saml_metadata_view +from .views import inactive_user_view, saml_metadata_view, lti_login_and_complete_view urlpatterns = patterns( '', url(r'^auth/inactive', inactive_user_view), url(r'^auth/saml/metadata.xml', saml_metadata_view), + url(r'^auth/login/(?Plti)/$', lti_login_and_complete_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 ef0233f33c..58fd17c784 100644 --- a/common/djangoapps/third_party_auth/views.py +++ b/common/djangoapps/third_party_auth/views.py @@ -3,11 +3,17 @@ 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.http import HttpResponse, HttpResponseServerError, Http404, HttpResponseNotAllowed from django.shortcuts import redirect +from django.views.decorators.csrf import csrf_exempt +import social +from social.apps.django_app.views import complete from social.apps.django_app.utils import load_strategy, load_backend +from social.utils import setting_name from .models import SAMLConfiguration +URL_NAMESPACE = getattr(settings, setting_name('URL_NAMESPACE'), None) or 'social' + def inactive_user_view(request): """ @@ -36,3 +42,15 @@ def saml_metadata_view(request): if not errors: return HttpResponse(content=metadata, content_type='text/xml') return HttpResponseServerError(content=', '.join(errors)) + + +@csrf_exempt +@social.apps.django_app.utils.psa('{0}:complete'.format(URL_NAMESPACE)) +def lti_login_and_complete_view(request, backend, *args, **kwargs): + """This is a combination login/complete due to LTI being a one step login""" + + if request.method != 'POST': + return HttpResponseNotAllowed('POST') + + request.backend.start() + return complete(request, backend, *args, **kwargs) diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py index a2eda85848..f6b6badfe8 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -187,7 +187,7 @@ def _third_party_auth_context(request, redirect_to): } if third_party_auth.is_enabled(): - for enabled in third_party_auth.provider.Registry.enabled(): + for enabled in third_party_auth.provider.Registry.accepting_logins(): info = { "id": enabled.provider_id, "name": enabled.name, @@ -208,12 +208,14 @@ def _third_party_auth_context(request, redirect_to): running_pipeline = pipeline.get(request) if running_pipeline is not None: 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 + if current_provider is not None: + 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): @@ -396,13 +398,14 @@ def account_settings_context(request): '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. + # with the particular provider, as long as the provider supports initiating a login. 'connect_url': pipeline.get_login_url( 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'), ), + 'accepts_logins': state.provider.accepts_logins, # 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.provider_id, state.association_id), diff --git a/lms/envs/aws.py b/lms/envs/aws.py index c86b3fa04b..d9cf8c9854 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -552,6 +552,7 @@ if FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): 'social.backends.linkedin.LinkedinOAuth2', 'social.backends.facebook.FacebookOAuth2', 'third_party_auth.saml.SAMLAuthBackend', + 'third_party_auth.lti.LTIAuthBackend', ]) + list(AUTHENTICATION_BACKENDS) ) @@ -566,6 +567,7 @@ if FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): SOCIAL_AUTH_SAML_SP_PRIVATE_KEY = AUTH_TOKENS.get('SOCIAL_AUTH_SAML_SP_PRIVATE_KEY', '') SOCIAL_AUTH_SAML_SP_PUBLIC_CERT = AUTH_TOKENS.get('SOCIAL_AUTH_SAML_SP_PUBLIC_CERT', '') SOCIAL_AUTH_OAUTH_SECRETS = AUTH_TOKENS.get('SOCIAL_AUTH_OAUTH_SECRETS', {}) + SOCIAL_AUTH_LTI_CONSUMER_SECRETS = AUTH_TOKENS.get('SOCIAL_AUTH_LTI_CONSUMER_SECRETS', {}) # 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) diff --git a/lms/envs/test.py b/lms/envs/test.py index 77f9282572..1e52d4297c 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -250,6 +250,7 @@ AUTHENTICATION_BACKENDS = ( 'social.backends.twitter.TwitterOAuth', 'third_party_auth.dummy.DummyBackend', 'third_party_auth.saml.SAMLAuthBackend', + 'third_party_auth.lti.LTIAuthBackend', ) + AUTHENTICATION_BACKENDS ################################## OPENID ##################################### 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 44204a5781..12cdda51f5 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 @@ -35,6 +35,7 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers 'id': 'oa2-network1', 'name': "Network1", 'connected': true, + 'accepts_logins': 'true', 'connect_url': 'yetanother1.com/auth/connect', 'disconnect_url': 'yetanother1.com/auth/disconnect' }, @@ -42,6 +43,7 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers 'id': 'oa2-network2', 'name': "Network2", 'connected': true, + 'accepts_logins': 'true', 'connect_url': 'yetanother2.com/auth/connect', 'disconnect_url': 'yetanother2.com/auth/disconnect' } diff --git a/lms/static/js/spec/student_account/account_settings_fields_spec.js b/lms/static/js/spec/student_account/account_settings_fields_spec.js index c924872f82..9d5b95616b 100644 --- a/lms/static/js/spec/student_account/account_settings_fields_spec.js +++ b/lms/static/js/spec/student_account/account_settings_fields_spec.js @@ -111,6 +111,7 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers helpMessage: '', valueAttribute: 'auth-yet-another', connected: true, + acceptsLogins: 'true', connectUrl: 'yetanother.com/auth/connect', disconnectUrl: 'yetanother.com/auth/disconnect' }); 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 706f3a0efb..dc3fa09be8 100644 --- a/lms/static/js/student_account/views/account_settings_factory.js +++ b/lms/static/js/student_account/views/account_settings_factory.js @@ -149,6 +149,7 @@ helpMessage: '', connected: provider.connected, connectUrl: provider.connect_url, + acceptsLogins: provider.accepts_logins, disconnectUrl: provider.disconnect_url }) }; diff --git a/lms/static/js/student_account/views/account_settings_fields.js b/lms/static/js/student_account/views/account_settings_fields.js index f20f7bff4a..e2cf8e3e26 100644 --- a/lms/static/js/student_account/views/account_settings_fields.js +++ b/lms/static/js/student_account/views/account_settings_fields.js @@ -116,11 +116,20 @@ }, render: function () { + var linkTitle; + if (this.options.connected) { + linkTitle = gettext('Unlink'); + } else if (this.options.acceptsLogins) { + linkTitle = gettext('Link') + } else { + linkTitle = '' + } + this.$el.html(this.template({ id: this.options.valueAttribute, title: this.options.title, screenReaderTitle: this.options.screenReaderTitle, - linkTitle: this.options.connected ? gettext('Unlink') : gettext('Link'), + linkTitle: linkTitle, linkHref: '', message: this.helpMessage })); diff --git a/lms/templates/login.html b/lms/templates/login.html index c6483df2a0..b0012d1d01 100644 --- a/lms/templates/login.html +++ b/lms/templates/login.html @@ -219,7 +219,7 @@ from microsite_configuration import microsite
- % for enabled in provider.Registry.enabled(): + % for enabled in provider.Registry.accepting_logins(): ## 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 c913be8466..46fc6c7d47 100644 --- a/lms/templates/register.html +++ b/lms/templates/register.html @@ -130,7 +130,7 @@ import calendar
- % for enabled in provider.Registry.enabled(): + % for enabled in provider.Registry.accepting_logins(): ## 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_profile/third_party_auth.html b/lms/templates/student_profile/third_party_auth.html index 6092ee7034..9eea38ddda 100644 --- a/lms/templates/student_profile/third_party_auth.html +++ b/lms/templates/student_profile/third_party_auth.html @@ -32,7 +32,7 @@ from third_party_auth import pipeline ## Translators: clicking on this removes the link between a user's edX account and their account with an external authentication provider (like Google or LinkedIn). ${_("Unlink")} - % else: + % elif state.provider.accepts_logins: ## Translators: clicking on this creates a link between a user's edX account and their account with an external authentication provider (like Google or LinkedIn). ${_("Link")} diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index 34efebcccd..042d5870c0 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -722,27 +722,28 @@ class RegistrationView(APIView): if running_pipeline: current_provider = third_party_auth.provider.Registry.get_from_pipeline(running_pipeline) - # Override username / email / full name - field_overrides = current_provider.get_register_form_data( - running_pipeline.get('kwargs') - ) + if current_provider: + # Override username / email / full name + field_overrides = current_provider.get_register_form_data( + running_pipeline.get('kwargs') + ) - for field_name in self.DEFAULT_FIELDS: - if field_name in field_overrides: - form_desc.override_field_properties( - field_name, default=field_overrides[field_name] - ) + for field_name in self.DEFAULT_FIELDS: + if field_name in field_overrides: + form_desc.override_field_properties( + field_name, default=field_overrides[field_name] + ) - # Hide the password field - form_desc.override_field_properties( - "password", - default="", - field_type="hidden", - required=False, - label="", - instructions="", - restrictions={} - ) + # Hide the password field + form_desc.override_field_properties( + "password", + default="", + field_type="hidden", + required=False, + label="", + instructions="", + restrictions={} + ) class PasswordResetView(APIView): From 9a83e99938eed2f31a05641553a6b01ff01e8507 Mon Sep 17 00:00:00 2001 From: William Ono Date: Thu, 13 Aug 2015 16:43:08 -0700 Subject: [PATCH 2/2] Avoid losing error messages when inside a frame --- lms/djangoapps/student_account/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py index f6b6badfe8..706ffaa94b 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -102,6 +102,7 @@ def login_and_registration_form(request, initial_mode="login"): 'third_party_auth_hint': third_party_auth_hint or '', 'platform_name': settings.PLATFORM_NAME, 'responsive': True, + 'allow_iframing': True, # Include form descriptions retrieved from the user API. # We could have the JS client make these requests directly,