Add grading functionality to LTI xmodule
Co-author: Alexander Kryklia <kryklia@edx.org> Co-author: Ned Batchelder <ned@edx.org> Co-author: Oleg Marchev <oleg@edx.org> Co-author: Valera Rozuvan <valera@edx.org> Co-author: polesye [BLD-384]
This commit is contained in:
@@ -1,21 +1,60 @@
|
||||
"""
|
||||
Module that allows to insert LTI tools to page.
|
||||
Learning Tools Interoperability (LTI) module.
|
||||
|
||||
Protocol is oauth1, LTI version is 1.1.1:
|
||||
http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html
|
||||
|
||||
Resources
|
||||
---------
|
||||
|
||||
Theoretical background and detailed specifications of LTI can be found on:
|
||||
|
||||
http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html
|
||||
|
||||
This module is based on the version 1.1.1 of the LTI specifications by the
|
||||
IMS Global authority. For authentication, it uses OAuth1.
|
||||
|
||||
When responding back to the LTI tool provider, we must issue a correct
|
||||
response. Types of responses and their message payload is available at:
|
||||
|
||||
Table A1.2 Interpretation of the 'CodeMajor/severity' matrix.
|
||||
http://www.imsglobal.org/gws/gwsv1p0/imsgws_wsdlBindv1p0.html
|
||||
|
||||
A resource to test the LTI protocol (PHP realization):
|
||||
|
||||
http://www.imsglobal.org/developers/LTI/test/v1p1/lms.php
|
||||
|
||||
|
||||
What is supported:
|
||||
------------------
|
||||
|
||||
1.) Display of simple LTI in iframe or a new window.
|
||||
2.) Multiple LTI components on a single page.
|
||||
3.) The use of multiple LTI providers per course.
|
||||
4.) Use of advanced LTI component that provides back a grade.
|
||||
a.) The LTI provider sends back a grade to a specified URL.
|
||||
b.) Currently only action "update" is supported. "Read", and "delete"
|
||||
actions initially weren't required.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import oauthlib.oauth1
|
||||
from oauthlib.oauth1.rfc5849 import signature
|
||||
import hashlib
|
||||
import base64
|
||||
import urllib
|
||||
import textwrap
|
||||
from lxml import etree
|
||||
from webob import Response
|
||||
import mock
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
from xmodule.editing_module import MetadataOnlyEditingDescriptor
|
||||
from xmodule.raw_module import EmptyDataRawDescriptor
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.x_module import XModule, module_attr
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from pkg_resources import resource_string
|
||||
from xblock.core import String, Scope, List
|
||||
from xblock.fields import Boolean
|
||||
from xblock.core import String, Scope, List, XBlock
|
||||
from xblock.fields import Boolean, Float
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,8 +68,8 @@ class LTIFields(object):
|
||||
Fields to define and obtain LTI tool from provider are set here,
|
||||
except credentials, which should be set in course settings::
|
||||
|
||||
`lti_id` is id to connect tool with credentials in course settings.
|
||||
`launch_url` is launch url of tool.
|
||||
`lti_id` is id to connect tool with credentials in course settings. It should not contain :: (double semicolon)
|
||||
`launch_url` is launch URL of tool.
|
||||
`custom_parameters` are additional parameters to navigate to proper book and book page.
|
||||
|
||||
For example, for Vitalsource provider, `launch_url` should be
|
||||
@@ -40,7 +79,7 @@ class LTIFields(object):
|
||||
vbid=put_book_id_here
|
||||
book_location=page/put_page_number_here
|
||||
|
||||
Default non-empty url for `launch_url` is needed due to oauthlib demand (url scheme should be presented)::
|
||||
Default non-empty URL for `launch_url` is needed due to oauthlib demand (URL scheme should be presented)::
|
||||
|
||||
https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136
|
||||
"""
|
||||
@@ -48,13 +87,15 @@ class LTIFields(object):
|
||||
launch_url = String(help="URL of the tool", default='http://www.example.com', scope=Scope.settings)
|
||||
custom_parameters = List(help="Custom parameters (vbid, book_location, etc..)", scope=Scope.settings)
|
||||
open_in_a_new_page = Boolean(help="Should LTI be opened in new page?", default=True, scope=Scope.settings)
|
||||
graded = Boolean(help="Grades will be considered in overall score.", default=False, scope=Scope.settings)
|
||||
weight = Float(help="Weight for student grades.", default=1.0, scope=Scope.settings)
|
||||
|
||||
|
||||
class LTIModule(LTIFields, XModule):
|
||||
'''
|
||||
"""
|
||||
Module provides LTI integration to course.
|
||||
|
||||
Except usual xmodule structure it proceeds with oauth signing.
|
||||
Except usual Xmodule structure it proceeds with OAuth signing.
|
||||
How it works::
|
||||
|
||||
1. Get credentials from course settings.
|
||||
@@ -71,14 +112,14 @@ class LTIModule(LTIFields, XModule):
|
||||
role
|
||||
*+ all custom parameters*
|
||||
|
||||
These parameters should be encoded and signed by *oauth1* together with
|
||||
These parameters should be encoded and signed by *OAuth1* together with
|
||||
`launch_url` and *POST* request type.
|
||||
|
||||
3. Signing proceeds with client key/secret pair obtained from course settings.
|
||||
That pair should be obtained from LTI provider and set into course settings by course author.
|
||||
After that signature and other oauth data are generated.
|
||||
After that signature and other OAuth data are generated.
|
||||
|
||||
Oauth data which is generated after signing is usual::
|
||||
OAuth data which is generated after signing is usual::
|
||||
|
||||
oauth_callback
|
||||
oauth_nonce
|
||||
@@ -89,47 +130,47 @@ class LTIModule(LTIFields, XModule):
|
||||
|
||||
|
||||
4. All that data is passed to form and sent to LTI provider server by browser via
|
||||
autosubmit via javascript.
|
||||
autosubmit via JavaScript.
|
||||
|
||||
Form example::
|
||||
|
||||
<form
|
||||
action="${launch_url}"
|
||||
name="ltiLaunchForm-${element_id}"
|
||||
class="ltiLaunchForm"
|
||||
method="post"
|
||||
target="ltiLaunchFrame-${element_id}"
|
||||
encType="application/x-www-form-urlencoded"
|
||||
>
|
||||
<input name="launch_presentation_return_url" value="" />
|
||||
<input name="lis_outcome_service_url" value="" />
|
||||
<input name="lis_result_sourcedid" value="" />
|
||||
<input name="lti_message_type" value="basic-lti-launch-request" />
|
||||
<input name="lti_version" value="LTI-1p0" />
|
||||
<input name="oauth_callback" value="about:blank" />
|
||||
<input name="oauth_consumer_key" value="${oauth_consumer_key}" />
|
||||
<input name="oauth_nonce" value="${oauth_nonce}" />
|
||||
<input name="oauth_signature_method" value="HMAC-SHA1" />
|
||||
<input name="oauth_timestamp" value="${oauth_timestamp}" />
|
||||
<input name="oauth_version" value="1.0" />
|
||||
<input name="user_id" value="${user_id}" />
|
||||
<input name="role" value="student" />
|
||||
<input name="oauth_signature" value="${oauth_signature}" />
|
||||
action="${launch_url}"
|
||||
name="ltiLaunchForm-${element_id}"
|
||||
class="ltiLaunchForm"
|
||||
method="post"
|
||||
target="ltiLaunchFrame-${element_id}"
|
||||
encType="application/x-www-form-urlencoded"
|
||||
>
|
||||
<input name="launch_presentation_return_url" value="" />
|
||||
<input name="lis_outcome_service_url" value="" />
|
||||
<input name="lis_result_sourcedid" value="" />
|
||||
<input name="lti_message_type" value="basic-lti-launch-request" />
|
||||
<input name="lti_version" value="LTI-1p0" />
|
||||
<input name="oauth_callback" value="about:blank" />
|
||||
<input name="oauth_consumer_key" value="${oauth_consumer_key}" />
|
||||
<input name="oauth_nonce" value="${oauth_nonce}" />
|
||||
<input name="oauth_signature_method" value="HMAC-SHA1" />
|
||||
<input name="oauth_timestamp" value="${oauth_timestamp}" />
|
||||
<input name="oauth_version" value="1.0" />
|
||||
<input name="user_id" value="${user_id}" />
|
||||
<input name="role" value="student" />
|
||||
<input name="oauth_signature" value="${oauth_signature}" />
|
||||
|
||||
<input name="custom_1" value="${custom_param_1_value}" />
|
||||
<input name="custom_2" value="${custom_param_2_value}" />
|
||||
<input name="custom_..." value="${custom_param_..._value}" />
|
||||
<input name="custom_1" value="${custom_param_1_value}" />
|
||||
<input name="custom_2" value="${custom_param_2_value}" />
|
||||
<input name="custom_..." value="${custom_param_..._value}" />
|
||||
|
||||
<input type="submit" value="Press to Launch" />
|
||||
</form>
|
||||
<input type="submit" value="Press to Launch" />
|
||||
</form>
|
||||
|
||||
5. LTI provider has same secret key and it signs data string via *oauth1* and compares signatures.
|
||||
5. LTI provider has same secret key and it signs data string via *OAuth1* and compares signatures.
|
||||
|
||||
If signatures are correct, LTI provider redirects iframe source to LTI tool web page,
|
||||
and LTI tool is rendered to iframe inside course.
|
||||
|
||||
Otherwise error message from LTI provider is generated.
|
||||
'''
|
||||
"""
|
||||
|
||||
js = {'js': [resource_string(__name__, 'js/src/lti/lti.js')]}
|
||||
css = {'scss': [resource_string(__name__, 'css/lti/lti.scss')]}
|
||||
@@ -180,21 +221,7 @@ class LTIModule(LTIFields, XModule):
|
||||
"tool_consumer_instance_contact_email",
|
||||
]
|
||||
|
||||
# Obtains client_key and client_secret credentials from current course:
|
||||
course_id = self.course_id
|
||||
course_location = CourseDescriptor.id_to_location(course_id)
|
||||
course = self.descriptor.runtime.modulestore.get_item(course_location)
|
||||
client_key = client_secret = ''
|
||||
|
||||
for lti_passport in course.lti_passports:
|
||||
try:
|
||||
lti_id, key, secret = [i.strip() for i in lti_passport.split(':')]
|
||||
except ValueError:
|
||||
raise LTIError('Could not parse LTI passport: {0!r}. \
|
||||
Should be "id:key:secret" string.'.format(lti_passport))
|
||||
if lti_id == self.lti_id.strip():
|
||||
client_key, client_secret = key, secret
|
||||
break
|
||||
client_key, client_secret = self.get_client_key_secret()
|
||||
|
||||
# parsing custom parameters to dict
|
||||
custom_parameters = {}
|
||||
@@ -214,12 +241,12 @@ class LTIModule(LTIFields, XModule):
|
||||
input_fields = self.oauth_params(
|
||||
custom_parameters,
|
||||
client_key,
|
||||
client_secret
|
||||
client_secret,
|
||||
)
|
||||
context = {
|
||||
'input_fields': input_fields,
|
||||
|
||||
# these params do not participate in oauth signing
|
||||
# These parameters do not participate in OAuth signing.
|
||||
'launch_url': self.launch_url.strip(),
|
||||
'element_id': self.location.html_id(),
|
||||
'element_class': self.category,
|
||||
@@ -229,12 +256,57 @@ class LTIModule(LTIFields, XModule):
|
||||
|
||||
return self.system.render_template('lti.html', context)
|
||||
|
||||
def get_user_id(self):
|
||||
user_id = self.runtime.anonymous_student_id
|
||||
assert user_id is not None
|
||||
return unicode(urllib.quote(user_id))
|
||||
|
||||
def get_outcome_service_url(self):
|
||||
"""
|
||||
Return URL for storing grades.
|
||||
"""
|
||||
uri = 'http://{host}{path}'.format(
|
||||
host=self.system.hostname,
|
||||
path=self.runtime.handler_url(self, 'grade_handler', thirdparty=True).rstrip('/?')
|
||||
)
|
||||
return uri
|
||||
|
||||
def get_resource_link_id(self):
|
||||
"""
|
||||
This is an opaque unique identifier that the TC guarantees will be unique
|
||||
within the TC for every placement of the link.
|
||||
|
||||
If the tool / activity is placed multiple times in the same context,
|
||||
each of those placements will be distinct.
|
||||
|
||||
This value will also change if the item is exported from one system or
|
||||
context and imported into another system or context.
|
||||
|
||||
This parameter is required.
|
||||
"""
|
||||
return unicode(urllib.quote(self.id))
|
||||
|
||||
def get_lis_result_sourcedid(self):
|
||||
"""
|
||||
This field contains an identifier that indicates the LIS Result Identifier (if any)
|
||||
associated with this launch. This field identifies a unique row and column within the
|
||||
TC gradebook. This field is unique for every combination of context_id / resource_link_id / user_id.
|
||||
This value may change for a particular resource_link_id / user_id from one launch to the next.
|
||||
The TP should only retain the most recent value for this field for a particular resource_link_id / user_id.
|
||||
This field is generally optional, but is required for grading.
|
||||
|
||||
context_id is - is an opaque identifier that uniquely identifies the context that contains
|
||||
the link being launched.
|
||||
lti_id should be context_id by meaning.
|
||||
"""
|
||||
return u':'.join(urllib.quote(i) for i in (self.lti_id, self.get_resource_link_id(), self.get_user_id()))
|
||||
|
||||
|
||||
def oauth_params(self, custom_parameters, client_key, client_secret):
|
||||
"""
|
||||
Signs request and returns signature and oauth parameters.
|
||||
Signs request and returns signature and OAuth parameters.
|
||||
|
||||
`custom_paramters` is dict of parsed `custom_parameter` field
|
||||
|
||||
`client_key` and `client_secret` are LTI tool credentials.
|
||||
|
||||
Also *anonymous student id* is passed to template and therefore to LTI provider.
|
||||
@@ -245,22 +317,23 @@ class LTIModule(LTIFields, XModule):
|
||||
client_secret=unicode(client_secret)
|
||||
)
|
||||
|
||||
user_id = self.runtime.anonymous_student_id
|
||||
assert user_id is not None
|
||||
|
||||
# must have parameters for correct signing from LTI:
|
||||
# Must have parameters for correct signing from LTI:
|
||||
body = {
|
||||
u'user_id': user_id,
|
||||
u'user_id': self.get_user_id(),
|
||||
u'oauth_callback': u'about:blank',
|
||||
u'lis_outcome_service_url': '',
|
||||
u'lis_result_sourcedid': '',
|
||||
u'launch_presentation_return_url': '',
|
||||
u'lti_message_type': u'basic-lti-launch-request',
|
||||
u'lti_version': 'LTI-1p0',
|
||||
u'role': u'student'
|
||||
u'role': u'student',
|
||||
|
||||
# Parameters required for grading:
|
||||
u'resource_link_id': self.get_resource_link_id(),
|
||||
u'lis_outcome_service_url': self.get_outcome_service_url(),
|
||||
u'lis_result_sourcedid': self.get_lis_result_sourcedid(),
|
||||
|
||||
}
|
||||
|
||||
# appending custom parameter for signing
|
||||
# Appending custom parameter for signing.
|
||||
body.update(custom_parameters)
|
||||
|
||||
headers = {
|
||||
@@ -274,9 +347,9 @@ class LTIModule(LTIFields, XModule):
|
||||
http_method=u'POST',
|
||||
body=body,
|
||||
headers=headers)
|
||||
except ValueError: # scheme not in url
|
||||
#https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136
|
||||
#Stubbing headers for now:
|
||||
except ValueError: # Scheme not in url.
|
||||
# https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136
|
||||
# Stubbing headers for now:
|
||||
headers = {
|
||||
u'Content-Type': u'application/x-www-form-urlencoded',
|
||||
u'Authorization': u'OAuth oauth_nonce="80966668944732164491378916897", \
|
||||
@@ -284,7 +357,7 @@ oauth_timestamp="1378916897", oauth_version="1.0", oauth_signature_method="HMAC-
|
||||
oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
|
||||
|
||||
params = headers['Authorization']
|
||||
# parse headers to pass to template as part of context:
|
||||
# Parse headers to pass to template as part of context:
|
||||
params = dict([param.strip().replace('"', '').split('=') for param in params.split(',')])
|
||||
|
||||
params[u'oauth_nonce'] = params[u'OAuth oauth_nonce']
|
||||
@@ -297,13 +370,217 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
|
||||
# So we need to decode signature back:
|
||||
params[u'oauth_signature'] = urllib.unquote(params[u'oauth_signature']).decode('utf8')
|
||||
|
||||
# add lti parameters to oauth parameters for sending in form
|
||||
# Add LTI parameters to OAuth parameters for sending in form.
|
||||
params.update(body)
|
||||
return params
|
||||
|
||||
def max_score(self):
|
||||
return self.weight
|
||||
|
||||
|
||||
@XBlock.handler
|
||||
def grade_handler(self, request, dispatch):
|
||||
"""
|
||||
This is called by courseware.module_render, to handle an AJAX call.
|
||||
|
||||
Used only for grading. Returns XML response.
|
||||
|
||||
Example of request body from LTI provider::
|
||||
|
||||
<?xml version = "1.0" encoding = "UTF-8"?>
|
||||
<imsx_POXEnvelopeRequest xmlns = "some_link (may be not required)">
|
||||
<imsx_POXHeader>
|
||||
<imsx_POXRequestHeaderInfo>
|
||||
<imsx_version>V1.0</imsx_version>
|
||||
<imsx_messageIdentifier>528243ba5241b</imsx_messageIdentifier>
|
||||
</imsx_POXRequestHeaderInfo>
|
||||
</imsx_POXHeader>
|
||||
<imsx_POXBody>
|
||||
<replaceResultRequest>
|
||||
<resultRecord>
|
||||
<sourcedGUID>
|
||||
<sourcedId>feb-123-456-2929::28883</sourcedId>
|
||||
</sourcedGUID>
|
||||
<result>
|
||||
<resultScore>
|
||||
<language>en-us</language>
|
||||
<textString>0.4</textString>
|
||||
</resultScore>
|
||||
</result>
|
||||
</resultRecord>
|
||||
</replaceResultRequest>
|
||||
</imsx_POXBody>
|
||||
</imsx_POXEnvelopeRequest>
|
||||
|
||||
Example of correct/incorrect answer XML body:: see response_xml_template.
|
||||
"""
|
||||
response_xml_template = textwrap.dedent("""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<imsx_POXEnvelopeResponse xmlns = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0">
|
||||
<imsx_POXHeader>
|
||||
<imsx_POXResponseHeaderInfo>
|
||||
<imsx_version>V1.0</imsx_version>
|
||||
<imsx_messageIdentifier>{imsx_messageIdentifier}</imsx_messageIdentifier>
|
||||
<imsx_statusInfo>
|
||||
<imsx_codeMajor>{imsx_codeMajor}</imsx_codeMajor>
|
||||
<imsx_severity>status</imsx_severity>
|
||||
<imsx_description>{imsx_description}</imsx_description>
|
||||
<imsx_messageRefIdentifier>
|
||||
</imsx_messageRefIdentifier>
|
||||
</imsx_statusInfo>
|
||||
</imsx_POXResponseHeaderInfo>
|
||||
</imsx_POXHeader>
|
||||
<imsx_POXBody>{response}</imsx_POXBody>
|
||||
</imsx_POXEnvelopeResponse>
|
||||
""")
|
||||
# Returns when `action` is unsupported.
|
||||
# Supported actions:
|
||||
# - replaceResultRequest.
|
||||
unsupported_values = {
|
||||
'imsx_codeMajor': 'unsupported',
|
||||
'imsx_description': 'Target does not support the requested operation.',
|
||||
'imsx_messageIdentifier': 'unknown',
|
||||
'response': ''
|
||||
}
|
||||
# Returns if:
|
||||
# - score is out of range;
|
||||
# - can't parse response from TP;
|
||||
# - can't verify OAuth signing or OAuth signing is incorrect.
|
||||
failure_values = {
|
||||
'imsx_codeMajor': 'failure',
|
||||
'imsx_description': 'The request has failed.',
|
||||
'imsx_messageIdentifier': 'unknown',
|
||||
'response': ''
|
||||
}
|
||||
|
||||
try:
|
||||
imsx_messageIdentifier, sourcedId, score, action = self.parse_grade_xml_body(request.body)
|
||||
except Exception:
|
||||
return Response(response_xml_template.format(**failure_values), content_type="application/xml")
|
||||
|
||||
# Verify OAuth signing.
|
||||
try:
|
||||
self.verify_oauth_body_sign(request)
|
||||
except (ValueError, LTIError):
|
||||
failure_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier)
|
||||
return Response(response_xml_template.format(**failure_values), content_type="application/xml")
|
||||
|
||||
|
||||
real_user = self.system.get_real_user(urllib.unquote(sourcedId.split(':')[-1]))
|
||||
if action == 'replaceResultRequest':
|
||||
self.system.publish(
|
||||
event={
|
||||
'event_name': 'grade',
|
||||
'value': score * self.max_score(),
|
||||
'max_value': self.max_score(),
|
||||
},
|
||||
custom_user=real_user
|
||||
)
|
||||
|
||||
values = {
|
||||
'imsx_codeMajor': 'success',
|
||||
'imsx_description': 'Score for {sourced_id} is now {score}'.format(sourced_id=sourcedId, score=score),
|
||||
'imsx_messageIdentifier': escape(imsx_messageIdentifier),
|
||||
'response': '<replaceResultResponse/>'
|
||||
}
|
||||
return Response(response_xml_template.format(**values), content_type="application/xml")
|
||||
|
||||
unsupported_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier)
|
||||
return Response(response_xml_template.format(**unsupported_values), content_type='application/xml')
|
||||
|
||||
|
||||
@classmethod
|
||||
def parse_grade_xml_body(cls, body):
|
||||
"""
|
||||
Parses XML from request.body and returns parsed data
|
||||
|
||||
XML body should contain nsmap with namespace, that is specified in LTI specs.
|
||||
|
||||
Returns tuple: imsx_messageIdentifier, sourcedId, score, action
|
||||
|
||||
Raises Exception if can't parse.
|
||||
"""
|
||||
lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"
|
||||
namespaces = {'def': lti_spec_namespace}
|
||||
|
||||
data = body.strip().encode('utf-8')
|
||||
parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8')
|
||||
root = etree.fromstring(data, parser=parser)
|
||||
|
||||
imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text
|
||||
sourcedId = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text
|
||||
score = root.xpath("//def:textString", namespaces=namespaces)[0].text
|
||||
action = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0].getchildren()[0].tag.replace('{'+lti_spec_namespace+'}', '')
|
||||
# Raise exception if score is not float or not in range 0.0-1.0 regarding spec.
|
||||
score = float(score)
|
||||
if not 0 <= score <= 1:
|
||||
raise LTIError
|
||||
|
||||
return imsx_messageIdentifier, sourcedId, score, action
|
||||
|
||||
def verify_oauth_body_sign(self, request):
|
||||
"""
|
||||
Verify grade request from LTI provider using OAuth body signing.
|
||||
|
||||
Uses http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html::
|
||||
|
||||
This specification extends the OAuth signature to include integrity checks on HTTP request bodies
|
||||
with content types other than application/x-www-form-urlencoded.
|
||||
|
||||
Arguments:
|
||||
request: DjangoWebobRequest.
|
||||
|
||||
Raises:
|
||||
LTIError if request is incorrect.
|
||||
"""
|
||||
|
||||
client_key, client_secret = self.get_client_key_secret()
|
||||
|
||||
headers = {
|
||||
'Authorization':unicode(request.headers.get('Authorization')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
|
||||
sha1 = hashlib.sha1()
|
||||
sha1.update(request.body)
|
||||
oauth_body_hash = base64.b64encode(sha1.hexdigest())
|
||||
|
||||
oauth_params = signature.collect_parameters(headers=headers, exclude_oauth_signature=False)
|
||||
oauth_headers =dict(oauth_params)
|
||||
oauth_signature = oauth_headers.pop('oauth_signature')
|
||||
|
||||
mock_request = mock.Mock(
|
||||
uri=unicode(urllib.unquote(request.url)),
|
||||
http_method=unicode(request.method),
|
||||
params=oauth_headers.items(),
|
||||
signature=oauth_signature
|
||||
)
|
||||
if (oauth_body_hash != oauth_headers.get('oauth_body_hash') or
|
||||
not signature.verify_hmac_sha1(mock_request, client_secret)):
|
||||
raise LTIError
|
||||
|
||||
def get_client_key_secret(self):
|
||||
"""
|
||||
Obtains client_key and client_secret credentials from current course.
|
||||
"""
|
||||
course_id = self.course_id
|
||||
course_location = CourseDescriptor.id_to_location(course_id)
|
||||
course = self.descriptor.runtime.modulestore.get_item(course_location)
|
||||
|
||||
for lti_passport in course.lti_passports:
|
||||
try:
|
||||
lti_id, key, secret = [i.strip() for i in lti_passport.split(':')]
|
||||
except ValueError:
|
||||
raise LTIError('Could not parse LTI passport: {0!r}. \
|
||||
Should be "id:key:secret" string.'.format(lti_passport))
|
||||
if lti_id == self.lti_id.strip():
|
||||
return key, secret
|
||||
return '', ''
|
||||
|
||||
class LTIDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor):
|
||||
"""
|
||||
Descriptor for LTI Xmodule.
|
||||
"""
|
||||
has_score = True
|
||||
module_class = LTIModule
|
||||
grade_handler = module_attr('grade_handler')
|
||||
|
||||
251
common/lib/xmodule/xmodule/tests/test_lti_unit.py
Normal file
251
common/lib/xmodule/xmodule/tests/test_lti_unit.py
Normal file
@@ -0,0 +1,251 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Test for LTI Xmodule functional logic."""
|
||||
|
||||
from mock import Mock, patch, PropertyMock
|
||||
import textwrap
|
||||
from lxml import etree
|
||||
from webob.request import Request
|
||||
from copy import copy
|
||||
import urllib
|
||||
|
||||
from xmodule.lti_module import LTIDescriptor
|
||||
|
||||
from . import LogicTest
|
||||
|
||||
|
||||
class LTIModuleTest(LogicTest):
|
||||
"""Logic tests for LTI module."""
|
||||
descriptor_class = LTIDescriptor
|
||||
|
||||
def setUp(self):
|
||||
super(LTIModuleTest, self).setUp()
|
||||
self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'}
|
||||
self.request_body_xml_template = textwrap.dedent("""
|
||||
<?xml version = "1.0" encoding = "UTF-8"?>
|
||||
<imsx_POXEnvelopeRequest xmlns = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0">
|
||||
<imsx_POXHeader>
|
||||
<imsx_POXRequestHeaderInfo>
|
||||
<imsx_version>V1.0</imsx_version>
|
||||
<imsx_messageIdentifier>{messageIdentifier}</imsx_messageIdentifier>
|
||||
</imsx_POXRequestHeaderInfo>
|
||||
</imsx_POXHeader>
|
||||
<imsx_POXBody>
|
||||
<{action}>
|
||||
<resultRecord>
|
||||
<sourcedGUID>
|
||||
<sourcedId>{sourcedId}</sourcedId>
|
||||
</sourcedGUID>
|
||||
<result>
|
||||
<resultScore>
|
||||
<language>en-us</language>
|
||||
<textString>{grade}</textString>
|
||||
</resultScore>
|
||||
</result>
|
||||
</resultRecord>
|
||||
</{action}>
|
||||
</imsx_POXBody>
|
||||
</imsx_POXEnvelopeRequest>
|
||||
""")
|
||||
self.system.get_real_user = Mock()
|
||||
self.xmodule.get_client_key_secret = Mock(return_value=('key', 'secret'))
|
||||
self.system.publish = Mock()
|
||||
|
||||
self.user_id = self.xmodule.runtime.anonymous_student_id
|
||||
self.lti_id = self.xmodule.lti_id
|
||||
self.module_id = '//MITx/999/lti/'
|
||||
|
||||
sourcedId = u':'.join(urllib.quote(i) for i in (self.lti_id, self.module_id, self.user_id))
|
||||
|
||||
self.DEFAULTS = {
|
||||
'sourcedId': sourcedId,
|
||||
'action': 'replaceResultRequest',
|
||||
'grade': '0.5',
|
||||
'messageIdentifier': '528243ba5241b',
|
||||
}
|
||||
|
||||
def get_request_body(self, params={}):
|
||||
data = copy(self.DEFAULTS)
|
||||
|
||||
data.update(params)
|
||||
return self.request_body_xml_template.format(**data)
|
||||
|
||||
def get_response_values(self, response):
|
||||
parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8')
|
||||
root = etree.fromstring(response.body.strip(), parser=parser)
|
||||
lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"
|
||||
namespaces = {'def': lti_spec_namespace}
|
||||
|
||||
code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text
|
||||
description = root.xpath("//def:imsx_description", namespaces=namespaces)[0].text
|
||||
messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text
|
||||
imsx_POXBody = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0]
|
||||
|
||||
try:
|
||||
action = imsx_POXBody.getchildren()[0].tag.replace('{'+lti_spec_namespace+'}', '')
|
||||
except Exception:
|
||||
action = None
|
||||
|
||||
return {
|
||||
'code_major': code_major,
|
||||
'description': description,
|
||||
'messageIdentifier': messageIdentifier,
|
||||
'action': action
|
||||
}
|
||||
|
||||
def test_authorization_header_not_present(self):
|
||||
"""
|
||||
Request has no Authorization header.
|
||||
This is an unknown service request, i.e., it is not a part of the original service specification.
|
||||
"""
|
||||
request = Request(self.environ)
|
||||
request.body = self.get_request_body()
|
||||
response = self.xmodule.grade_handler(request, '')
|
||||
real_response = self.get_response_values(response)
|
||||
expected_response = {
|
||||
'action': None,
|
||||
'code_major': 'failure',
|
||||
'description': 'The request has failed.',
|
||||
'messageIdentifier': self.DEFAULTS['messageIdentifier'],
|
||||
}
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertDictEqual(expected_response, real_response)
|
||||
|
||||
def test_authorization_header_empty(self):
|
||||
"""
|
||||
Request Authorization header has no value.
|
||||
This is an unknown service request, i.e., it is not a part of the original service specification.
|
||||
"""
|
||||
request = Request(self.environ)
|
||||
request.authorization = "bad authorization header"
|
||||
request.body = self.get_request_body()
|
||||
response = self.xmodule.grade_handler(request, '')
|
||||
real_response = self.get_response_values(response)
|
||||
expected_response = {
|
||||
'action': None,
|
||||
'code_major': 'failure',
|
||||
'description': 'The request has failed.',
|
||||
'messageIdentifier': self.DEFAULTS['messageIdentifier'],
|
||||
}
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertDictEqual(expected_response, real_response)
|
||||
|
||||
def test_grade_not_in_range(self):
|
||||
"""
|
||||
Grade returned from Tool Provider is outside the range 0.0-1.0.
|
||||
"""
|
||||
self.xmodule.verify_oauth_body_sign = Mock()
|
||||
request = Request(self.environ)
|
||||
request.body = self.get_request_body(params={'grade': '10'})
|
||||
response = self.xmodule.grade_handler(request, '')
|
||||
real_response = self.get_response_values(response)
|
||||
expected_response = {
|
||||
'action': None,
|
||||
'code_major': 'failure',
|
||||
'description': 'The request has failed.',
|
||||
'messageIdentifier': 'unknown',
|
||||
}
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertDictEqual(expected_response, real_response)
|
||||
|
||||
def test_bad_grade_decimal(self):
|
||||
"""
|
||||
Grade returned from Tool Provider doesn't use a period as the decimal point.
|
||||
"""
|
||||
self.xmodule.verify_oauth_body_sign = Mock()
|
||||
request = Request(self.environ)
|
||||
request.body = self.get_request_body(params={'grade': '0,5'})
|
||||
response = self.xmodule.grade_handler(request, '')
|
||||
real_response = self.get_response_values(response)
|
||||
expected_response = {
|
||||
'action': None,
|
||||
'code_major': 'failure',
|
||||
'description': 'The request has failed.',
|
||||
'messageIdentifier': 'unknown',
|
||||
}
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertDictEqual(expected_response, real_response)
|
||||
|
||||
def test_unsupported_action(self):
|
||||
"""
|
||||
Action returned from Tool Provider isn't supported.
|
||||
`replaceResultRequest` is supported only.
|
||||
"""
|
||||
self.xmodule.verify_oauth_body_sign = Mock()
|
||||
request = Request(self.environ)
|
||||
request.body = self.get_request_body({'action': 'wrongAction'})
|
||||
response = self.xmodule.grade_handler(request, '')
|
||||
real_response = self.get_response_values(response)
|
||||
expected_response = {
|
||||
'action': None,
|
||||
'code_major': 'unsupported',
|
||||
'description': 'Target does not support the requested operation.',
|
||||
'messageIdentifier': self.DEFAULTS['messageIdentifier'],
|
||||
}
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertDictEqual(expected_response, real_response)
|
||||
|
||||
def test_good_request(self):
|
||||
"""
|
||||
Response from Tool Provider is correct.
|
||||
"""
|
||||
self.xmodule.verify_oauth_body_sign = Mock()
|
||||
request = Request(self.environ)
|
||||
request.body = self.get_request_body()
|
||||
response = self.xmodule.grade_handler(request, '')
|
||||
code_major, description, messageIdentifier, action = self.get_response_values(response)
|
||||
description_expected = 'Score for {sourcedId} is now {score}'.format(
|
||||
sourcedId=self.DEFAULTS['sourcedId'],
|
||||
score=self.DEFAULTS['grade'],
|
||||
)
|
||||
real_response = self.get_response_values(response)
|
||||
expected_response = {
|
||||
'action': 'replaceResultResponse',
|
||||
'code_major': 'success',
|
||||
'description': description_expected,
|
||||
'messageIdentifier': self.DEFAULTS['messageIdentifier'],
|
||||
}
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertDictEqual(expected_response, real_response)
|
||||
|
||||
def test_user_id(self):
|
||||
expected_user_id = unicode(urllib.quote(self.xmodule.runtime.anonymous_student_id))
|
||||
real_user_id = self.xmodule.get_user_id()
|
||||
self.assertEqual(real_user_id, expected_user_id)
|
||||
|
||||
def test_outcome_service_url(self):
|
||||
expected_outcome_service_url = 'http://{host}{path}'.format(
|
||||
host=self.xmodule.runtime.hostname,
|
||||
path=self.xmodule.runtime.handler_url(self.xmodule, 'grade_handler', thirdparty=True).rstrip('/?')
|
||||
)
|
||||
|
||||
real_outcome_service_url = self.xmodule.get_outcome_service_url()
|
||||
self.assertEqual(real_outcome_service_url, expected_outcome_service_url)
|
||||
|
||||
def test_resource_link_id(self):
|
||||
with patch('xmodule.lti_module.LTIModule.id', new_callable=PropertyMock) as mock_id:
|
||||
mock_id.return_value = self.module_id
|
||||
expected_resource_link_id = unicode(urllib.quote(self.module_id))
|
||||
real_resource_link_id = self.xmodule.get_resource_link_id()
|
||||
self.assertEqual(real_resource_link_id, expected_resource_link_id)
|
||||
|
||||
|
||||
def test_lis_result_sourcedid(self):
|
||||
with patch('xmodule.lti_module.LTIModule.id', new_callable=PropertyMock) as mock_id:
|
||||
mock_id.return_value = self.module_id
|
||||
expected_sourcedId = u':'.join(urllib.quote(i) for i in (self.lti_id, self.module_id, self.user_id))
|
||||
real_lis_result_sourcedid = self.xmodule.get_lis_result_sourcedid()
|
||||
self.assertEqual(real_lis_result_sourcedid, expected_sourcedId)
|
||||
|
||||
|
||||
def test_verify_oauth_body_sign(self):
|
||||
pass
|
||||
|
||||
def test_client_key_secret(self):
|
||||
pass
|
||||
|
||||
@@ -371,7 +371,6 @@ class XModule(XModuleMixin, HTMLSnippet, XBlock): # pylint: disable=abstract-me
|
||||
See the HTML module for a simple example.
|
||||
"""
|
||||
|
||||
|
||||
has_score = descriptor_attr('has_score')
|
||||
_field_data_cache = descriptor_attr('_field_data_cache')
|
||||
_field_data = descriptor_attr('_field_data')
|
||||
@@ -968,7 +967,7 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs
|
||||
anonymous_student_id='', course_id=None,
|
||||
open_ended_grading_interface=None, s3_interface=None,
|
||||
cache=None, can_execute_unsafe_code=None, replace_course_urls=None,
|
||||
replace_jump_to_id_urls=None, error_descriptor_class=None, **kwargs):
|
||||
replace_jump_to_id_urls=None, error_descriptor_class=None, get_real_user=None, **kwargs):
|
||||
"""
|
||||
Create a closure around the system environment.
|
||||
|
||||
@@ -1053,6 +1052,8 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs
|
||||
self.error_descriptor_class = error_descriptor_class
|
||||
self.xmodule_instance = None
|
||||
|
||||
self.get_real_user = get_real_user
|
||||
|
||||
def get(self, attr):
|
||||
""" provide uniform access to attributes (like etree)."""
|
||||
return self.__dict__.get(attr)
|
||||
|
||||
Reference in New Issue
Block a user