Merge pull request #8930 from ubc/wmono/lti-sso
Feature: LTI third-party-auth providers
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
202
common/djangoapps/third_party_auth/lti.py
Normal file
202
common/djangoapps/third_party_auth/lti.py
Normal file
@@ -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
|
||||
@@ -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']
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
some=garbage&values=provided
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
159
common/djangoapps/third_party_auth/tests/specs/test_lti.py
Normal file
159
common/djangoapps/third_party_auth/tests/specs/test_lti.py
Normal file
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
133
common/djangoapps/third_party_auth/tests/test_lti.py
Normal file
133
common/djangoapps/third_party_auth/tests/test_lti.py
Normal file
@@ -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)
|
||||
@@ -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) """
|
||||
|
||||
@@ -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/(?P<backend>lti)/$', lti_login_and_complete_view),
|
||||
url(r'^auth/', include('social.apps.django_app.urls', namespace='social')),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
@@ -187,7 +188,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 +209,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 +399,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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 #####################################
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
helpMessage: '',
|
||||
connected: provider.connected,
|
||||
connectUrl: provider.connect_url,
|
||||
acceptsLogins: provider.accepts_logins,
|
||||
disconnectUrl: provider.disconnect_url
|
||||
})
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
|
||||
@@ -219,7 +219,7 @@ from microsite_configuration import microsite
|
||||
|
||||
<div class="form-actions form-third-party-auth">
|
||||
|
||||
% 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).
|
||||
<button type="submit" class="button button-primary button-${enabled.provider_id} login-${enabled.provider_id}" onclick="thirdPartySignin(event, '${pipeline_url[enabled.provider_id]}');"><span class="icon fa ${enabled.icon_class}"></span>${_('Sign in with {provider_name}').format(provider_name=enabled.name)}</button>
|
||||
% endfor
|
||||
|
||||
@@ -130,7 +130,7 @@ import calendar
|
||||
|
||||
<div class="form-actions form-third-party-auth">
|
||||
|
||||
% 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).
|
||||
<button type="submit" class="button button-primary button-${enabled.provider_id} register-${enabled.provider_id}" onclick="thirdPartySignin(event, '${pipeline_urls[enabled.provider_id]}');"><span class="icon fa ${enabled.icon_class}"></span>${_('Sign up with {provider_name}').format(provider_name=enabled.name)}</button>
|
||||
% endfor
|
||||
|
||||
@@ -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")}
|
||||
</a>
|
||||
% else:
|
||||
% elif state.provider.accepts_logins:
|
||||
<a href="${pipeline.get_login_url(state.provider.provider_id, pipeline.AUTH_ENTRY_PROFILE)}">
|
||||
## 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")}
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user