Commit 4d119480 by William Ono

Support LTI third-party-auth providers

PR #8930
parent 97a11c11
......@@ -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)
"""
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
......@@ -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,6 +26,10 @@ 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):
......@@ -33,6 +37,11 @@ class Registry(object):
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."""
if '-' not in provider_id: # Check format - see models.py:ProviderConfig
......@@ -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)
lti_message_type=basic-lti-launch-request&lti_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
some=garbage&values=provided
\ No newline at end of file
lti_message_type=basic-lti-launch-request&lti_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
lti_message_type=basic-lti-launch-request&lti_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
lti_message_type=basic-lti-launch-request&lti_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
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
"""
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&#34;: &#34;LTI Test Tool Consumer&#34;', register_response.content)
self.assertIn('&#34;errorMessage&#34;: 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&#34;: &#34;Tool Consumer with Secret in Settings&#34;',
register_response.content
)
self.assertIn('&#34;errorMessage&#34;: null', register_response.content)
"""
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()
"""
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&lti_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)
......@@ -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),
......
......@@ -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')
)
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={}
)
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]
)
# Hide the password field
form_desc.override_field_properties(
"password",
default="",
field_type="hidden",
required=False,
label="",
instructions="",
restrictions={}
)
class PasswordResetView(APIView):
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment