/common/lib/xmodule/xmodule/lti_module.py

https://github.com/adewes/edx-platform · Python · 676 lines · 567 code · 38 blank · 71 comment · 30 complexity · 473b8da32a131b6fc46e3c6d923c6b04 MD5 · raw file

  1. """
  2. Learning Tools Interoperability (LTI) module.
  3. Resources
  4. ---------
  5. Theoretical background and detailed specifications of LTI can be found on:
  6. http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html
  7. This module is based on the version 1.1.1 of the LTI specifications by the
  8. IMS Global authority. For authentication, it uses OAuth1.
  9. When responding back to the LTI tool provider, we must issue a correct
  10. response. Types of responses and their message payload is available at:
  11. Table A1.2 Interpretation of the 'CodeMajor/severity' matrix.
  12. http://www.imsglobal.org/gws/gwsv1p0/imsgws_wsdlBindv1p0.html
  13. A resource to test the LTI protocol (PHP realization):
  14. http://www.imsglobal.org/developers/LTI/test/v1p1/lms.php
  15. What is supported:
  16. ------------------
  17. 1.) Display of simple LTI in iframe or a new window.
  18. 2.) Multiple LTI components on a single page.
  19. 3.) The use of multiple LTI providers per course.
  20. 4.) Use of advanced LTI component that provides back a grade.
  21. a.) The LTI provider sends back a grade to a specified URL.
  22. b.) Currently only action "update" is supported. "Read", and "delete"
  23. actions initially weren't required.
  24. """
  25. import logging
  26. import oauthlib.oauth1
  27. from oauthlib.oauth1.rfc5849 import signature
  28. import hashlib
  29. import base64
  30. import urllib
  31. import textwrap
  32. from lxml import etree
  33. from webob import Response
  34. import mock
  35. from xml.sax.saxutils import escape
  36. from xmodule.editing_module import MetadataOnlyEditingDescriptor
  37. from xmodule.raw_module import EmptyDataRawDescriptor
  38. from xmodule.x_module import XModule, module_attr
  39. from xmodule.course_module import CourseDescriptor
  40. from pkg_resources import resource_string
  41. from xblock.core import String, Scope, List, XBlock
  42. from xblock.fields import Boolean, Float
  43. log = logging.getLogger(__name__)
  44. class LTIError(Exception):
  45. pass
  46. class LTIFields(object):
  47. """
  48. Fields to define and obtain LTI tool from provider are set here,
  49. except credentials, which should be set in course settings::
  50. `lti_id` is id to connect tool with credentials in course settings. It should not contain :: (double semicolon)
  51. `launch_url` is launch URL of tool.
  52. `custom_parameters` are additional parameters to navigate to proper book and book page.
  53. For example, for Vitalsource provider, `launch_url` should be
  54. *https://bc-staging.vitalsource.com/books/book*,
  55. and to get to proper book and book page, you should set custom parameters as::
  56. vbid=put_book_id_here
  57. book_location=page/put_page_number_here
  58. Default non-empty URL for `launch_url` is needed due to oauthlib demand (URL scheme should be presented)::
  59. https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136
  60. """
  61. display_name = String(display_name="Display Name", help="Display name for this module", scope=Scope.settings, default="LTI")
  62. lti_id = String(help="Id of the tool", default='', scope=Scope.settings)
  63. launch_url = String(help="URL of the tool", default='http://www.example.com', scope=Scope.settings)
  64. custom_parameters = List(help="Custom parameters (vbid, book_location, etc..)", scope=Scope.settings)
  65. open_in_a_new_page = Boolean(help="Should LTI be opened in new page?", default=True, scope=Scope.settings)
  66. graded = Boolean(help="Grades will be considered in overall score.", default=False, scope=Scope.settings)
  67. weight = Float(
  68. help="Weight for student grades.",
  69. default=1.0,
  70. scope=Scope.settings,
  71. values={"min": 0},
  72. )
  73. has_score = Boolean(help="Does this LTI module have score?", default=False, scope=Scope.settings)
  74. class LTIModule(LTIFields, XModule):
  75. """
  76. Module provides LTI integration to course.
  77. Except usual Xmodule structure it proceeds with OAuth signing.
  78. How it works::
  79. 1. Get credentials from course settings.
  80. 2. There is minimal set of parameters need to be signed (presented for Vitalsource)::
  81. user_id
  82. oauth_callback
  83. lis_outcome_service_url
  84. lis_result_sourcedid
  85. launch_presentation_return_url
  86. lti_message_type
  87. lti_version
  88. roles
  89. *+ all custom parameters*
  90. These parameters should be encoded and signed by *OAuth1* together with
  91. `launch_url` and *POST* request type.
  92. 3. Signing proceeds with client key/secret pair obtained from course settings.
  93. That pair should be obtained from LTI provider and set into course settings by course author.
  94. After that signature and other OAuth data are generated.
  95. OAuth data which is generated after signing is usual::
  96. oauth_callback
  97. oauth_nonce
  98. oauth_consumer_key
  99. oauth_signature_method
  100. oauth_timestamp
  101. oauth_version
  102. 4. All that data is passed to form and sent to LTI provider server by browser via
  103. autosubmit via JavaScript.
  104. Form example::
  105. <form
  106. action="${launch_url}"
  107. name="ltiLaunchForm-${element_id}"
  108. class="ltiLaunchForm"
  109. method="post"
  110. target="ltiLaunchFrame-${element_id}"
  111. encType="application/x-www-form-urlencoded"
  112. >
  113. <input name="launch_presentation_return_url" value="" />
  114. <input name="lis_outcome_service_url" value="" />
  115. <input name="lis_result_sourcedid" value="" />
  116. <input name="lti_message_type" value="basic-lti-launch-request" />
  117. <input name="lti_version" value="LTI-1p0" />
  118. <input name="oauth_callback" value="about:blank" />
  119. <input name="oauth_consumer_key" value="${oauth_consumer_key}" />
  120. <input name="oauth_nonce" value="${oauth_nonce}" />
  121. <input name="oauth_signature_method" value="HMAC-SHA1" />
  122. <input name="oauth_timestamp" value="${oauth_timestamp}" />
  123. <input name="oauth_version" value="1.0" />
  124. <input name="user_id" value="${user_id}" />
  125. <input name="role" value="student" />
  126. <input name="oauth_signature" value="${oauth_signature}" />
  127. <input name="custom_1" value="${custom_param_1_value}" />
  128. <input name="custom_2" value="${custom_param_2_value}" />
  129. <input name="custom_..." value="${custom_param_..._value}" />
  130. <input type="submit" value="Press to Launch" />
  131. </form>
  132. 5. LTI provider has same secret key and it signs data string via *OAuth1* and compares signatures.
  133. If signatures are correct, LTI provider redirects iframe source to LTI tool web page,
  134. and LTI tool is rendered to iframe inside course.
  135. Otherwise error message from LTI provider is generated.
  136. """
  137. css = {'scss': [resource_string(__name__, 'css/lti/lti.scss')]}
  138. def get_input_fields(self):
  139. # LTI provides a list of default parameters that might be passed as
  140. # part of the POST data. These parameters should not be prefixed.
  141. # Likewise, The creator of an LTI link can add custom key/value parameters
  142. # to a launch which are to be included with the launch of the LTI link.
  143. # In this case, we will automatically add `custom_` prefix before this parameters.
  144. # See http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html#_Toc316828520
  145. PARAMETERS = [
  146. "lti_message_type",
  147. "lti_version",
  148. "resource_link_title",
  149. "resource_link_description",
  150. "user_image",
  151. "lis_person_name_given",
  152. "lis_person_name_family",
  153. "lis_person_name_full",
  154. "lis_person_contact_email_primary",
  155. "lis_person_sourcedid",
  156. "role_scope_mentor",
  157. "context_type",
  158. "context_title",
  159. "context_label",
  160. "launch_presentation_locale",
  161. "launch_presentation_document_target",
  162. "launch_presentation_css_url",
  163. "launch_presentation_width",
  164. "launch_presentation_height",
  165. "launch_presentation_return_url",
  166. "tool_consumer_info_product_family_code",
  167. "tool_consumer_info_version",
  168. "tool_consumer_instance_guid",
  169. "tool_consumer_instance_name",
  170. "tool_consumer_instance_description",
  171. "tool_consumer_instance_url",
  172. "tool_consumer_instance_contact_email",
  173. ]
  174. client_key, client_secret = self.get_client_key_secret()
  175. # parsing custom parameters to dict
  176. custom_parameters = {}
  177. for custom_parameter in self.custom_parameters:
  178. try:
  179. param_name, param_value = [p.strip() for p in custom_parameter.split('=', 1)]
  180. except ValueError:
  181. _ = self.runtime.service(self, "i18n").ugettext
  182. msg = _('Could not parse custom parameter: {custom_parameter}. Should be "x=y" string.').format(
  183. custom_parameter="{0!r}".format(custom_parameter)
  184. )
  185. raise LTIError(msg)
  186. # LTI specs: 'custom_' should be prepended before each custom parameter, as pointed in link above.
  187. if param_name not in PARAMETERS:
  188. param_name = 'custom_' + param_name
  189. custom_parameters[unicode(param_name)] = unicode(param_value)
  190. return self.oauth_params(
  191. custom_parameters,
  192. client_key,
  193. client_secret,
  194. )
  195. def get_context(self):
  196. """
  197. Returns a context.
  198. """
  199. return {
  200. 'input_fields': self.get_input_fields(),
  201. # These parameters do not participate in OAuth signing.
  202. 'launch_url': self.launch_url.strip(),
  203. 'element_id': self.location.html_id(),
  204. 'element_class': self.category,
  205. 'open_in_a_new_page': self.open_in_a_new_page,
  206. 'display_name': self.display_name,
  207. 'form_url': self.runtime.handler_url(self, 'preview_handler').rstrip('/?'),
  208. }
  209. def get_html(self):
  210. """
  211. Renders parameters to template.
  212. """
  213. return self.system.render_template('lti.html', self.get_context())
  214. @XBlock.handler
  215. def preview_handler(self, _, __):
  216. """
  217. This is called to get context with new oauth params to iframe.
  218. """
  219. template = self.system.render_template('lti_form.html', self.get_context())
  220. return Response(template, content_type='text/html')
  221. def get_user_id(self):
  222. user_id = self.runtime.anonymous_student_id
  223. assert user_id is not None
  224. return unicode(urllib.quote(user_id))
  225. def get_outcome_service_url(self):
  226. """
  227. Return URL for storing grades.
  228. To test LTI on sandbox we must use http scheme.
  229. While testing locally and on Jenkins, mock_lti_server use http.referer
  230. to obtain scheme, so it is ok to have http(s) anyway.
  231. """
  232. scheme = 'http' if 'sandbox' in self.system.hostname or self.system.debug else 'https'
  233. uri = '{scheme}://{host}{path}'.format(
  234. scheme=scheme,
  235. host=self.system.hostname,
  236. path=self.runtime.handler_url(self, 'grade_handler', thirdparty=True).rstrip('/?')
  237. )
  238. return uri
  239. def get_resource_link_id(self):
  240. """
  241. This is an opaque unique identifier that the TC guarantees will be unique
  242. within the TC for every placement of the link.
  243. If the tool / activity is placed multiple times in the same context,
  244. each of those placements will be distinct.
  245. This value will also change if the item is exported from one system or
  246. context and imported into another system or context.
  247. This parameter is required.
  248. Example: u'edx.org-i4x-2-3-lti-31de800015cf4afb973356dbe81496df'
  249. Hostname, edx.org,
  250. makes resource_link_id change on import to another system.
  251. Last part of location, location.name - 31de800015cf4afb973356dbe81496df,
  252. is random hash, updated by course_id,
  253. this makes resource_link_id unique inside single course.
  254. First part of location is tag-org-course-category, i4x-2-3-lti.
  255. Location.name itself does not change on import to another course,
  256. but org and course_id change.
  257. So together with org and course_id in a form of
  258. i4x-2-3-lti-31de800015cf4afb973356dbe81496df this part of resource_link_id:
  259. makes resource_link_id to be unique among courses inside same system.
  260. """
  261. return unicode(urllib.quote("{}-{}".format(self.system.hostname, self.location.html_id())))
  262. def get_lis_result_sourcedid(self):
  263. """
  264. This field contains an identifier that indicates the LIS Result Identifier (if any)
  265. associated with this launch. This field identifies a unique row and column within the
  266. TC gradebook. This field is unique for every combination of context_id / resource_link_id / user_id.
  267. This value may change for a particular resource_link_id / user_id from one launch to the next.
  268. The TP should only retain the most recent value for this field for a particular resource_link_id / user_id.
  269. This field is generally optional, but is required for grading.
  270. """
  271. return "{context}:{resource_link}:{user_id}".format(
  272. context=urllib.quote(self.context_id),
  273. resource_link=self.get_resource_link_id(),
  274. user_id=self.get_user_id()
  275. )
  276. def get_course(self):
  277. """
  278. Return course by course id.
  279. """
  280. course_location = CourseDescriptor.id_to_location(self.course_id)
  281. course = self.descriptor.runtime.modulestore.get_item(course_location)
  282. return course
  283. @property
  284. def context_id(self):
  285. """
  286. Return context_id.
  287. context_id is an opaque identifier that uniquely identifies the context (e.g., a course)
  288. that contains the link being launched.
  289. """
  290. return self.course_id
  291. @property
  292. def role(self):
  293. """
  294. Get system user role and convert it to LTI role.
  295. """
  296. roles = {
  297. 'student': u'Student',
  298. 'staff': u'Administrator',
  299. 'instructor': u'Instructor',
  300. }
  301. return roles.get(self.system.get_user_role(), u'Student')
  302. def oauth_params(self, custom_parameters, client_key, client_secret):
  303. """
  304. Signs request and returns signature and OAuth parameters.
  305. `custom_paramters` is dict of parsed `custom_parameter` field
  306. `client_key` and `client_secret` are LTI tool credentials.
  307. Also *anonymous student id* is passed to template and therefore to LTI provider.
  308. """
  309. client = oauthlib.oauth1.Client(
  310. client_key=unicode(client_key),
  311. client_secret=unicode(client_secret)
  312. )
  313. # Must have parameters for correct signing from LTI:
  314. body = {
  315. u'user_id': self.get_user_id(),
  316. u'oauth_callback': u'about:blank',
  317. u'launch_presentation_return_url': '',
  318. u'lti_message_type': u'basic-lti-launch-request',
  319. u'lti_version': 'LTI-1p0',
  320. u'roles': self.role,
  321. # Parameters required for grading:
  322. u'resource_link_id': self.get_resource_link_id(),
  323. u'lis_result_sourcedid': self.get_lis_result_sourcedid(),
  324. u'context_id': self.context_id,
  325. }
  326. if self.has_score:
  327. body.update({
  328. u'lis_outcome_service_url': self.get_outcome_service_url()
  329. })
  330. # Appending custom parameter for signing.
  331. body.update(custom_parameters)
  332. headers = {
  333. # This is needed for body encoding:
  334. 'Content-Type': 'application/x-www-form-urlencoded',
  335. }
  336. try:
  337. __, headers, __ = client.sign(
  338. unicode(self.launch_url.strip()),
  339. http_method=u'POST',
  340. body=body,
  341. headers=headers)
  342. except ValueError: # Scheme not in url.
  343. # https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136
  344. # Stubbing headers for now:
  345. headers = {
  346. u'Content-Type': u'application/x-www-form-urlencoded',
  347. u'Authorization': u'OAuth oauth_nonce="80966668944732164491378916897", \
  348. oauth_timestamp="1378916897", oauth_version="1.0", oauth_signature_method="HMAC-SHA1", \
  349. oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
  350. params = headers['Authorization']
  351. # Parse headers to pass to template as part of context:
  352. params = dict([param.strip().replace('"', '').split('=') for param in params.split(',')])
  353. params[u'oauth_nonce'] = params[u'OAuth oauth_nonce']
  354. del params[u'OAuth oauth_nonce']
  355. # oauthlib encodes signature with
  356. # 'Content-Type': 'application/x-www-form-urlencoded'
  357. # so '='' becomes '%3D'.
  358. # We send form via browser, so browser will encode it again,
  359. # So we need to decode signature back:
  360. params[u'oauth_signature'] = urllib.unquote(params[u'oauth_signature']).decode('utf8')
  361. # Add LTI parameters to OAuth parameters for sending in form.
  362. params.update(body)
  363. return params
  364. def max_score(self):
  365. return self.weight if self.has_score else None
  366. @XBlock.handler
  367. def grade_handler(self, request, dispatch):
  368. """
  369. This is called by courseware.module_render, to handle an AJAX call.
  370. Used only for grading. Returns XML response.
  371. Example of request body from LTI provider::
  372. <?xml version = "1.0" encoding = "UTF-8"?>
  373. <imsx_POXEnvelopeRequest xmlns = "some_link (may be not required)">
  374. <imsx_POXHeader>
  375. <imsx_POXRequestHeaderInfo>
  376. <imsx_version>V1.0</imsx_version>
  377. <imsx_messageIdentifier>528243ba5241b</imsx_messageIdentifier>
  378. </imsx_POXRequestHeaderInfo>
  379. </imsx_POXHeader>
  380. <imsx_POXBody>
  381. <replaceResultRequest>
  382. <resultRecord>
  383. <sourcedGUID>
  384. <sourcedId>feb-123-456-2929::28883</sourcedId>
  385. </sourcedGUID>
  386. <result>
  387. <resultScore>
  388. <language>en-us</language>
  389. <textString>0.4</textString>
  390. </resultScore>
  391. </result>
  392. </resultRecord>
  393. </replaceResultRequest>
  394. </imsx_POXBody>
  395. </imsx_POXEnvelopeRequest>
  396. Example of correct/incorrect answer XML body:: see response_xml_template.
  397. """
  398. response_xml_template = textwrap.dedent("""\
  399. <?xml version="1.0" encoding="UTF-8"?>
  400. <imsx_POXEnvelopeResponse xmlns = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0">
  401. <imsx_POXHeader>
  402. <imsx_POXResponseHeaderInfo>
  403. <imsx_version>V1.0</imsx_version>
  404. <imsx_messageIdentifier>{imsx_messageIdentifier}</imsx_messageIdentifier>
  405. <imsx_statusInfo>
  406. <imsx_codeMajor>{imsx_codeMajor}</imsx_codeMajor>
  407. <imsx_severity>status</imsx_severity>
  408. <imsx_description>{imsx_description}</imsx_description>
  409. <imsx_messageRefIdentifier>
  410. </imsx_messageRefIdentifier>
  411. </imsx_statusInfo>
  412. </imsx_POXResponseHeaderInfo>
  413. </imsx_POXHeader>
  414. <imsx_POXBody>{response}</imsx_POXBody>
  415. </imsx_POXEnvelopeResponse>
  416. """)
  417. # Returns when `action` is unsupported.
  418. # Supported actions:
  419. # - replaceResultRequest.
  420. unsupported_values = {
  421. 'imsx_codeMajor': 'unsupported',
  422. 'imsx_description': 'Target does not support the requested operation.',
  423. 'imsx_messageIdentifier': 'unknown',
  424. 'response': ''
  425. }
  426. # Returns if:
  427. # - score is out of range;
  428. # - can't parse response from TP;
  429. # - can't verify OAuth signing or OAuth signing is incorrect.
  430. failure_values = {
  431. 'imsx_codeMajor': 'failure',
  432. 'imsx_description': 'The request has failed.',
  433. 'imsx_messageIdentifier': 'unknown',
  434. 'response': ''
  435. }
  436. try:
  437. imsx_messageIdentifier, sourcedId, score, action = self.parse_grade_xml_body(request.body)
  438. except Exception as e:
  439. error_message = "Request body XML parsing error: " + escape(e.message)
  440. log.debug("[LTI]: " + error_message)
  441. failure_values['imsx_description'] = error_message
  442. return Response(response_xml_template.format(**failure_values), content_type="application/xml")
  443. # Verify OAuth signing.
  444. try:
  445. self.verify_oauth_body_sign(request)
  446. except (ValueError, LTIError) as e:
  447. failure_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier)
  448. error_message = "OAuth verification error: " + escape(e.message)
  449. failure_values['imsx_description'] = error_message
  450. log.debug("[LTI]: " + error_message)
  451. return Response(response_xml_template.format(**failure_values), content_type="application/xml")
  452. real_user = self.system.get_real_user(urllib.unquote(sourcedId.split(':')[-1]))
  453. if not real_user: # that means we can't save to database, as we do not have real user id.
  454. failure_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier)
  455. failure_values['imsx_description'] = "User not found."
  456. return Response(response_xml_template.format(**failure_values), content_type="application/xml")
  457. if action == 'replaceResultRequest':
  458. self.system.publish(
  459. self,
  460. 'grade',
  461. {
  462. 'value': score * self.max_score(),
  463. 'max_value': self.max_score(),
  464. 'user_id': real_user.id,
  465. }
  466. )
  467. values = {
  468. 'imsx_codeMajor': 'success',
  469. 'imsx_description': 'Score for {sourced_id} is now {score}'.format(sourced_id=sourcedId, score=score),
  470. 'imsx_messageIdentifier': escape(imsx_messageIdentifier),
  471. 'response': '<replaceResultResponse/>'
  472. }
  473. log.debug("[LTI]: Grade is saved.")
  474. return Response(response_xml_template.format(**values), content_type="application/xml")
  475. unsupported_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier)
  476. log.debug("[LTI]: Incorrect action.")
  477. return Response(response_xml_template.format(**unsupported_values), content_type='application/xml')
  478. @classmethod
  479. def parse_grade_xml_body(cls, body):
  480. """
  481. Parses XML from request.body and returns parsed data
  482. XML body should contain nsmap with namespace, that is specified in LTI specs.
  483. Returns tuple: imsx_messageIdentifier, sourcedId, score, action
  484. Raises Exception if can't parse.
  485. """
  486. lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"
  487. namespaces = {'def': lti_spec_namespace}
  488. data = body.strip().encode('utf-8')
  489. parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8')
  490. root = etree.fromstring(data, parser=parser)
  491. imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text
  492. sourcedId = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text
  493. score = root.xpath("//def:textString", namespaces=namespaces)[0].text
  494. action = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0].getchildren()[0].tag.replace('{'+lti_spec_namespace+'}', '')
  495. # Raise exception if score is not float or not in range 0.0-1.0 regarding spec.
  496. score = float(score)
  497. if not 0 <= score <= 1:
  498. raise LTIError('score value outside the permitted range of 0-1.')
  499. return imsx_messageIdentifier, sourcedId, score, action
  500. def verify_oauth_body_sign(self, request):
  501. """
  502. Verify grade request from LTI provider using OAuth body signing.
  503. Uses http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html::
  504. This specification extends the OAuth signature to include integrity checks on HTTP request bodies
  505. with content types other than application/x-www-form-urlencoded.
  506. Arguments:
  507. request: DjangoWebobRequest.
  508. Raises:
  509. LTIError if request is incorrect.
  510. """
  511. client_key, client_secret = self.get_client_key_secret()
  512. headers = {
  513. 'Authorization':unicode(request.headers.get('Authorization')),
  514. 'Content-Type': 'application/x-www-form-urlencoded',
  515. }
  516. sha1 = hashlib.sha1()
  517. sha1.update(request.body)
  518. oauth_body_hash = base64.b64encode(sha1.digest())
  519. oauth_params = signature.collect_parameters(headers=headers, exclude_oauth_signature=False)
  520. oauth_headers =dict(oauth_params)
  521. oauth_signature = oauth_headers.pop('oauth_signature')
  522. mock_request = mock.Mock(
  523. uri=unicode(urllib.unquote(request.url)),
  524. http_method=unicode(request.method),
  525. params=oauth_headers.items(),
  526. signature=oauth_signature
  527. )
  528. if oauth_body_hash != oauth_headers.get('oauth_body_hash'):
  529. raise LTIError("OAuth body hash verification is failed.")
  530. if not signature.verify_hmac_sha1(mock_request, client_secret):
  531. raise LTIError("OAuth signature verification is failed.")
  532. def get_client_key_secret(self):
  533. """
  534. Obtains client_key and client_secret credentials from current course.
  535. """
  536. course = self.get_course()
  537. for lti_passport in course.lti_passports:
  538. try:
  539. lti_id, key, secret = [i.strip() for i in lti_passport.split(':')]
  540. except ValueError:
  541. _ = self.runtime.service(self, "i18n").ugettext
  542. msg = _('Could not parse LTI passport: {lti_passport}. Should be "id:key:secret" string.').format(
  543. lti_passport='{0!r}'.format(lti_passport)
  544. )
  545. raise LTIError(msg)
  546. if lti_id == self.lti_id.strip():
  547. return key, secret
  548. return '', ''
  549. class LTIDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor):
  550. """
  551. Descriptor for LTI Xmodule.
  552. """
  553. module_class = LTIModule
  554. grade_handler = module_attr('grade_handler')
  555. preview_handler = module_attr('preview_handler')