Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion common/djangoapps/student/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}


Expand Down
9 changes: 5 additions & 4 deletions common/djangoapps/student/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
25 changes: 24 additions & 1 deletion common/djangoapps/third_party_auth/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
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


Expand Down Expand Up @@ -88,3 +88,26 @@ def get_readonly_fields(self, request, obj=None):
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 changes: 202 additions & 0 deletions common/djangoapps/third_party_auth/lti.py
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 this is a lot clearer. I appreciate the comment too.


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
Loading