lti2 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ require 'lti2_commons/signer'
2
+ require 'lti2_commons/message_support'
3
+ require 'lti2_commons/substitution_support'
4
+
5
+ include Lti2Commons::Signer
6
+ include Lti2Commons::MessageSupport
7
+ include Lti2Commons::SubstitutionSupport
8
+
9
+ module Lti2Commons
10
+ module Core
11
+ def lti2_launch(user, link, return_url)
12
+ tool_proxy = link.resource.tool.get_tool_proxy
13
+
14
+ tool = link.resource.tool
15
+ tool_name = tool_proxy.first_at('tool_profile.product_instance.product_info.product_name.default_value')
16
+ fail "Tool #{tool_name} is currently disabled" unless tool.is_enabled
17
+
18
+ base_url = tool_proxy.select('tool_profile.base_url_choice', 'selector.applies_to',
19
+ 'MessageHandler', 'default_base_url')
20
+ resource_type = link.resource.resource_type
21
+ resource_handler_node = tool_proxy.search('tool_profile.resource_handler', { 'resource_type' => resource_type }, '@')
22
+ resource_handler = JsonWrapper.new(resource_handler_node)
23
+ message = resource_handler.search('@..message', { 'message_type' => 'basic-lti-launch-request' }, '@')
24
+ path = message['path']
25
+ tp_parameters = message['parameter']
26
+ service_endpoint = base_url + path
27
+
28
+ enrollment = Enrollment.where(admin_user_id: user.id, course_id: link.course.id).first
29
+ if enrollment
30
+ role = enrollment.role
31
+ else
32
+ role = user.role
33
+ end
34
+
35
+ tool_consumer_registry = Rails.application.config.tool_consumer_registry
36
+ parameters = {
37
+ 'lti_version' => tool_proxy.first_at('lti_version'),
38
+ 'lti_message_type' => 'basic-lti-launch-request',
39
+ 'resource_link_id' => link.id.to_s,
40
+ 'user_id' => user.id.to_s,
41
+ 'roles' => role,
42
+ 'launch_presentation_return_url' => "#{tool_consumer_registry.tc_deployment_url}#{return_url}",
43
+
44
+ # optional
45
+ 'context_id' => link.course.id.to_s
46
+ }
47
+
48
+ # add parameters from ToolProxy
49
+ tp_parameters.each do |parameter|
50
+ name = parameter['name']
51
+ if parameter.key? 'fixed'
52
+ value = parameter['fixed']
53
+ elsif parameter.key? 'variable'
54
+ value = parameter['variable']
55
+ else
56
+ value = '[link-to-resolve]'
57
+ end
58
+ parameters[name] = value
59
+ end
60
+
61
+ # add parameters from Link
62
+ link_parameter_json = link.link_parameters
63
+ if link_parameter_json
64
+ link_parameters = JSON.parse(link_parameter_json)
65
+ parameters.merge! link_parameters
66
+ end
67
+
68
+ # auto-create result (if required) and add to reference to parameters
69
+ capabilities = resource_handler.first_at('message[0].enabled_capability')
70
+ if capabilities && capabilities.include?('Result.autocreate')
71
+ if link.grade_item_id
72
+ grade_result = GradeResult.where(link_id: link.id, admin_user_id: user.id).first
73
+ # TEST ONLY
74
+ # grade_result.delete if grade_result
75
+ # grade_result = nil
76
+
77
+ if grade_result
78
+ # note that a nil grade_result still let's us through
79
+ unless grade_result.result.nil?
80
+ fail 'Grade result associated with this tool launch has already been filled in'
81
+ end
82
+ else
83
+ grade_result = GradeResult.new
84
+ grade_result.link_id = link.id
85
+ grade_result.admin_user_id = user.id
86
+ grade_result.save
87
+ end
88
+ end
89
+ end
90
+
91
+ # perform variable substition on all parameters
92
+ resolver = Resolver.new
93
+ resolver.add_resolver('$User', user.method(:user_resolver))
94
+ resolver.add_resolver('$Person', user.method(:person_resolver))
95
+ course = Course.find(parameters['context_id'])
96
+ resolver.add_resolver('$CourseOffering', course.method(:course_resolver))
97
+ resolver.add_resolver('$Result', grade_result.method(:grade_result_resolver)) if grade_result
98
+
99
+ # and resolve and normalize
100
+ final_parameters = {}
101
+ parameters.each do |k, v|
102
+ # only apply substitution when the value looks like a candidate
103
+ if v =~ /^\$\w+\.\w/
104
+ resolved_value = resolver.resolve(v)
105
+ else
106
+ resolved_value = v
107
+ end
108
+ if known_lti2_parameters.include?(k) || deprecated_lti_parameters.include?(k)
109
+ final_parameters[k] = v
110
+ else
111
+ name = 'custom_' + k
112
+ lti1_name = slugify(name)
113
+ final_parameters[lti1_name] = resolved_value unless name == lti1_name
114
+ final_parameters[name] = resolved_value
115
+ end
116
+ end
117
+
118
+ key = link.resource.tool.key
119
+ secret = link.resource.tool.secret
120
+
121
+ signed_request = Signer.create_signed_request(service_endpoint, 'post', key, secret, final_parameters)
122
+ puts "TC Signed Request: #{signed_request.signature_base_string}"
123
+ puts 'before'
124
+ puts Rails.application.config.wire_log.inspect
125
+ puts 'after'
126
+ body = MessageSupport.create_lti_message_body(
127
+ service_endpoint, final_parameters, Rails.application.config.wire_log, 'Lti Launch')
128
+ puts body
129
+ body
130
+ end
131
+
132
+ private
133
+
134
+ def deprecated_lti_parameters
135
+ ['context_title', 'context_label', 'resource_link_title', 'resource_link_description',
136
+ 'lis_person_name_given', 'lis_person_name_family', 'lis_person_name_full', 'lis_person_contact_email_primary',
137
+ 'user_image', 'lis_person_sourcedid', 'lis_course_offering_sourcedid', 'lis_course_section_sourcedid',
138
+ 'tool_consumer_instance_name', 'tool_consumer_instance_description', 'tool_consumer_instance_url',
139
+ 'tool_consumer_instance_contact_email']
140
+ end
141
+
142
+ def known_lti2_parameters
143
+ ['lti_message_type', 'lti_version', 'user_id', 'roles', 'resource_link_id',
144
+ 'context_id', 'context_type',
145
+ 'launch_presentation_locale', 'launch_presentation_document_target', 'launch_presentation_css_url',
146
+ 'launch_presentation_width', 'launch_presentation_height', 'launch_presentation_return_url',
147
+ 'reg_key', 'reg_password', 'tc_profile_url']
148
+ end
149
+
150
+ def slugify(name)
151
+ result = ''
152
+ name.each_char do |c|
153
+ result << (c =~ /\w/ ? c.downcase : '_')
154
+ end
155
+ result
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,218 @@
1
+ require 'httparty'
2
+
3
+ module Lti2Commons
4
+ # Module to support LTI 1 and 2 secure messaging.
5
+ # This messaging is documented in the LTI 2 Security Document
6
+ #
7
+ # LTI 2 defines two types of LTI secure messaging:
8
+ # 1. LTI Messages
9
+ # This is the model used exclusively in LTI 1.
10
+ # It is also used in LTI 2 for user-submitted actions such as LtiLaunch and ToolDeployment.
11
+ #
12
+ # It works the following way:
13
+ # 1. LTI parameters are signed by OAuth.
14
+ # 2. The message is marshalled into an HTML Form with the params
15
+ # specified in form fields.
16
+ # 3. The form is sent to the browser with a redirect.
17
+ # 4. An attached Javascript script 'auto-submits' the form.
18
+ #
19
+ # This structure appears to the Tool Provider as a user submission with all user browser context
20
+ # intact.
21
+ #
22
+ # 2. LTI Services
23
+ # This is a standard REST web service with OAuth message security added.
24
+ # In LTI 2.0 Services are only defined for Tool Provider --> Tool Consumer services;
25
+ # such as, GetToolConsumerProfile, RegisterToolProxy, and LTI 2 Outcomes.
26
+ # LTI 2.x will add Tool Consumer --> Tool Provider services using the same machinery.
27
+
28
+ module MessageSupport
29
+ TIMEOUT = 300
30
+
31
+ # Convenience method signs and then invokes create_lti_message_from_signed_request
32
+ #
33
+ # @params launch_url [String] Launch url
34
+ # @params parameters [Hash] Full set of params for message
35
+ # @return [String] Post body ready for use
36
+ #
37
+ def create_lti_message_body(launch_url, parameters, wire_log = nil, title = nil, is_open_in_external_window = false)
38
+ result = create_message_header(launch_url, is_open_in_external_window)
39
+ result += create_message_body(parameters)
40
+ result += create_message_footer(is_open_in_external_window)
41
+
42
+ if wire_log
43
+ wire_log.timestamp
44
+ wire_log.raw_log((title.nil?) ? 'LtiMessage' : "LtiMessage: #{title}")
45
+ wire_log.raw_log "LaunchUrl: #{launch_url}"
46
+ wire_log.raw_log result.strip
47
+ wire_log.newline
48
+ wire_log.flush
49
+ end
50
+
51
+ result
52
+ end
53
+
54
+ # Creates an LTI Message (POST body) ready for redirecting to the launch_url.
55
+ # Note that the is_include_oauth_params option specifies that 'oauth_' params are
56
+ # included in the body. This option should be false when sender is putting them in the
57
+ # HTTP Authorization header (now recommended).
58
+ #
59
+ # @param params [Hash] Full set of params for message (including OAuth provided params)
60
+ # @return [String] Post body ready for use
61
+ #
62
+ def create_lti_message_body_from_signed_request(signed_request, is_include_oauth_params = true,
63
+ is_open_in_external_window = false)
64
+ result = create_message_header(signed_request.uri, is_open_in_external_window)
65
+ result += create_message_body(signed_request.parameters, is_include_oauth_params)
66
+ result += create_message_footer(is_open_in_external_window)
67
+ result
68
+ end
69
+
70
+ # Invokes an LTI Service.
71
+ # This is fully-compliant REST request suitable for LTI server-to-server services.
72
+ #
73
+ # @param request [Request] Signed Request encapsulates everything needed for service.
74
+ def invoke_service(request, wire_log = nil, title = nil, other_headers = {})
75
+ uri = request.uri.to_s
76
+ # set_headers_proc = lambda { |http|
77
+ # http.headers['Authorization'] = request.oauth_header
78
+ # http.headers['Content-Type'] = request.content_type if request.content_type
79
+ # http.headers['Accept'] = request.content_type if request.content_type
80
+ # # http.headers['Content-Length'] = request.body.length if request.body
81
+ # }
82
+ method = request.method.downcase
83
+
84
+ headers = {}
85
+ headers['Authorization'] = request.oauth_header
86
+ headers['Content-Type'] = request.content_type if request.content_type
87
+ headers['Accept'] = request.accept if request.accept
88
+ headers['Content-Length'] = request.body.length.to_s if request.body
89
+ headers.merge!(other_headers)
90
+
91
+ parameters = request.parameters
92
+ output_parameters = {}
93
+ parameters.each { |k, v| output_parameters[k] = v unless k =~ /^oauth_/ }
94
+
95
+ (write_wirelog_header wire_log, title, request.method, uri, headers, parameters, request.body, output_parameters) if wire_log
96
+
97
+ full_uri = uri
98
+ full_uri += '?' unless uri.include? '?'
99
+ full_uri += '&' unless full_uri =~ /[?&]$/
100
+ output_parameters.each_pair do |key, _value|
101
+ full_uri << '&' unless key == output_parameters.keys.first
102
+ full_uri << "#{URI.encode(key.to_s)}=#{URI.encode(output_parameters[key] || '')}"
103
+ end
104
+
105
+ case method
106
+ when 'get'
107
+ response = HTTParty.get(full_uri, headers: headers, timeout: TIMEOUT)
108
+ when 'post'
109
+ response = HTTParty.post(full_uri, body: request.body, headers: headers, timeout: TIMEOUT)
110
+ when 'put'
111
+ response = HTTParty.put(full_uri, body: request.body, headers: headers, timeout: TIMEOUT)
112
+ when 'delete'
113
+ response = HTTParty.delete(full_uri, headers: headers, timeout: TIMEOUT)
114
+ end
115
+
116
+ wire_log.log_response(response, title) if wire_log
117
+
118
+ response
119
+ end
120
+
121
+ def invoke_unsigned_service(uri, method, params = {}, headers = {},
122
+ data = nil, wire_log = nil, title = nil)
123
+ full_uri = uri
124
+ full_uri += '?' unless uri.include? '?'
125
+ full_uri += '&' unless full_uri =~ /[?&]$/
126
+ params.each_pair do |key, _value|
127
+ full_uri << '&' unless key == params.keys.first
128
+ full_uri << "#{URI.encode(key.to_s)}=#{URI.encode(params[key])}"
129
+ end
130
+
131
+ write_wirelog_header(wire_log, title, method, uri, headers, params, data, {}) if wire_log
132
+
133
+ case method
134
+ when 'get'
135
+ response = HTTParty.get(full_uri, headers: headers, timeout: 120)
136
+ when 'post'
137
+ response = HTTParty.post(full_uri, body: data, headers: headers, timeout: 120)
138
+ when 'put'
139
+ response = HTTParty.put(full_uri, body: data, headers: headers, timeout: 120)
140
+ when 'delete'
141
+ response = HTTParty.delete(full_uri, headers: headers, timeout: 120)
142
+ end
143
+
144
+ wire_log.log_response(response, title) if wire_log
145
+
146
+ response
147
+ end
148
+
149
+ private
150
+
151
+ def create_message_header(launch_url, is_open_in_external_window = false)
152
+ attribute_for_external_window = is_open_in_external_window ? 'target="_blank"' : ''
153
+ %Q(
154
+ <div id="ltiLaunchFormSubmitArea">
155
+ <form action="#{launch_url}" #{attribute_for_external_window}
156
+ name="ltiLaunchForm" id="ltiLaunchForm" method="post"
157
+ encType="application/x-www-form-urlencoded">
158
+ )
159
+ end
160
+
161
+ def create_message_body(params, is_include_oauth_params = true)
162
+ result = ''
163
+ params.each_pair do |k, v|
164
+ if is_include_oauth_params || ! (k =~ /^oauth_/)
165
+ result +=
166
+ %Q( <input type="hidden" name="#{k}" value="#{CGI.escapeHTML(v)}"/>\n)
167
+ end
168
+ end
169
+ result
170
+ end
171
+
172
+ def create_message_footer(is_open_in_external_window=false)
173
+ footer = ""
174
+ if is_open_in_external_window
175
+ footer += %Q{
176
+ <a href="/admin/launches" target="_self">Return to Admin</a>\n
177
+ }
178
+ end
179
+ footer += %Q{
180
+ </form>
181
+ </div>
182
+ <script language="javascript">
183
+ document.ltiLaunchForm.submit();
184
+ </script>
185
+ }
186
+ footer
187
+ end
188
+
189
+ def set_http_headers(http, request)
190
+ http.headers['Authorization'] = request.oauth_header
191
+ http.headers['Content-Type'] = request.content_type if request.content_type
192
+ http.headers['Content-Length'] = request.body.length if request.body
193
+ end
194
+
195
+ def write_wirelog_header(wire_log, title, method, uri, headers = {},
196
+ parameters = {}, body = nil, output_parameters = {})
197
+ wire_log.timestamp
198
+ wire_log.raw_log((title.nil?) ? 'LtiService' : "LtiService: #{title}")
199
+ wire_log.raw_log("#{method.upcase} #{uri}")
200
+ unless headers.blank?
201
+ wire_log.raw_log('Headers:')
202
+ headers.each { |k, v| wire_log.raw_log("#{k}: #{v}") }
203
+ end
204
+ parameters.each { |k, v| output_parameters[k] = v unless k =~ /^oauth_/ }
205
+
206
+ if output_parameters.length > 0
207
+ wire_log.raw_log('Parameters:')
208
+ output_parameters.each { |k, v| wire_log.raw_log("#{k}: #{v}") }
209
+ end
210
+ if body
211
+ wire_log.raw_log('Body:')
212
+ wire_log.raw_log(body)
213
+ end
214
+ wire_log.newline
215
+ wire_log.flush
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,179 @@
1
+ require 'oauth/request_proxy/base'
2
+
3
+ module OAuth
4
+ module OAuthProxy
5
+ # RequestProxy for Hashes to facilitate simpler signature creation.
6
+ # Usage:
7
+ # request = OAuth::RequestProxy.proxy \
8
+ # "method" => "iq",
9
+ # "uri" => [from, to] * "&",
10
+ # "parameters" => {
11
+ # "oauth_consumer_key" => oauth_consumer_key,
12
+ # "oauth_token" => oauth_token,
13
+ # "oauth_signature_method" => "HMAC-SHA1"
14
+ # }
15
+ #
16
+ # signature = OAuth::Signature.sign \
17
+ # request,
18
+ # consumer_secret: oauth_consumer_secret,
19
+ # token_secret: oauth_token_secret,
20
+
21
+ # allow a certain amount of clock skew between servers
22
+ CLOCK_SKEW_ALLOWANCE_IN_SECS = 300
23
+
24
+ # nonce must be unique for some period of time
25
+ NONCE_REPLAY_UNIQUE_WITHIN_SECS = CLOCK_SKEW_ALLOWANCE_IN_SECS
26
+
27
+ class OAuthRequest < OAuth::RequestProxy::Base
28
+ proxies Hash
29
+
30
+ attr_accessor :body, :content_type, :accept
31
+
32
+ def self.collect_rack_parameters(rack_request)
33
+ parameters = HashWithIndifferentAccess.new
34
+ parameters.merge!(rack_request.query_parameters)
35
+ parameters.merge!(self.parse_authorization_header(rack_request.headers['HTTP_AUTHORIZATION']))
36
+ @content_type = rack_request.headers['CONTENT_TYPE']
37
+ @accept = rack_request.headers['ACCEPT']
38
+ if @content_type == 'application/x-www-form-urlencoded'
39
+ parameters.merge!(rack_request.request_parameters)
40
+ end
41
+ parameters
42
+ end
43
+
44
+ def self.create_from_rack_request(rack_request)
45
+ parameters = self.collect_rack_parameters(rack_request)
46
+ result = OAuth::OAuthProxy::OAuthRequest.new(
47
+ 'method' => rack_request.method,
48
+ 'uri' => rack_request.url,
49
+ 'parameters' => parameters
50
+ )
51
+ rack_request.body.rewind
52
+ result.body = rack_request.body.read
53
+ rack_request.body.rewind
54
+ result
55
+ end
56
+
57
+ def self.parse_authorization_header(authorization_header)
58
+ result = {}
59
+ if authorization_header =~ /^OAuth/
60
+ authorization_header[6..-1].split(',').inject({}) do |_h, part|
61
+ parts = part.split('=')
62
+ name = parts[0].strip.intern
63
+ value = parts[1..-1].join('=').strip
64
+ value.gsub!(/\A['"]+|['"]+\Z/, '')
65
+ result[name] = Rack::Utils.unescape(value) unless name == :realm
66
+ end
67
+ end
68
+ Rails.logger.info "AuthHdr_Parms: #{result.inspect}"
69
+ result
70
+ end
71
+
72
+ def final_uri
73
+ @request['final_uri']
74
+ end
75
+
76
+ def log(msg)
77
+ Rails.logger.info(msg)
78
+ end
79
+
80
+ def parameters
81
+ @request['parameters']
82
+ end
83
+
84
+ def method
85
+ @request['method']
86
+ end
87
+
88
+ def normalized_uri
89
+ super
90
+ rescue
91
+ # if this is a non-standard URI, it may not parse properly
92
+ # in that case, assume that it's already been normalized
93
+ uri
94
+ end
95
+
96
+ def uri
97
+ @request['uri']
98
+ end
99
+
100
+ # Creates the value of an OAuth body hash
101
+ #
102
+ # @param launch_url [String] Content to be body signed
103
+ # @return [String] Signature base string (useful for debugging signature problems)
104
+ #
105
+ def compute_oauth_body_hash(content)
106
+ Base64.encode64(Digest::SHA1.digest(content.chomp)).gsub(/\n/, '')
107
+ end
108
+
109
+ # A shallow+1 copy
110
+ #
111
+ def copy
112
+ result = OAuth::OAuthProxy::OAuthRequest.new(
113
+ 'method' => self.method.dup,
114
+ 'uri' => self.uri.dup,
115
+ 'parameters' => self.parameters.dup
116
+ )
117
+ result.body = self.body.dup if self.body
118
+ result
119
+ end
120
+
121
+ def is_timestamp_expired?(timestampString)
122
+ timestamp = Time.at(timestampString.to_i)
123
+ now = Time.now
124
+ (now - timestamp).abs > CLOCK_SKEW_ALLOWANCE_IN_SECS
125
+ end
126
+
127
+ # Validates an OAuth request using the OAuth Gem - https://github.com/oauth/oauth-ruby
128
+ #
129
+ # @return [Bool] Whether the request was valid
130
+ def verify_signature?(secret, nonce_cache, is_handle_error_not_raise_exception = true, ignore_timestamp_and_nonce = false)
131
+ log 'in verify_signature'
132
+ test_request = self.copy
133
+ test_signature = test_request.sign(consumer_secret: secret)
134
+ # log "DEBUG: signed"
135
+ begin
136
+ unless self.oauth_signature == test_signature
137
+ log "Secret: #{secret}"
138
+ log "Verify_signature--send_signature: #{self.oauth_signature} test_signature: #{test_signature}"
139
+ log "Verify signature_base_string: #{self.signature_base_string}"
140
+ fail 'Invalid signature'
141
+ end
142
+ unless ignore_timestamp_and_nonce
143
+ fail 'Timestamp expired' if is_timestamp_expired? self.oauth_timestamp
144
+ fail 'Duplicate nonce to one already received' if nonce_cache.fetch(self.oauth_nonce)
145
+ end
146
+ nonce_cache.store(self.oauth_nonce, '<who-cares>')
147
+
148
+ # check body-signing if oauth_body_signature
149
+ if self.body && self.parameters.key?('oauth_body_hash')
150
+ fail 'Invalid signature of message body' unless compute_oauth_body_hash(self.body) == self.parameters['oauth_body_hash']
151
+ end
152
+ [true, test_request.signature_base_string]
153
+ rescue Exception => e
154
+ log(e.message)
155
+ if is_handle_error_not_raise_exception
156
+ [false, test_request.signature_base_string]
157
+ else
158
+ raise e.message
159
+ end
160
+ end
161
+ end
162
+
163
+ # Runs validation logic but always returns true
164
+ #
165
+ # @return [Bool] Whether the request was valid
166
+ #
167
+ def verify_signature_always?(secret, nonce_cache, is_handle_error_not_raise_exception = true,
168
+ ignore_timestamp_and_nonce = false)
169
+ test_request = self.copy
170
+ test_signature = test_request.sign(consumer_secret: secret)
171
+ log "TC Signature: #{test_signature}"
172
+ log "TP Signature: #{self.oauth_signature}"
173
+ log "Signature_Base_String: #{test_request.signature_base_string}"
174
+ # log "Authorization_Header: #{request.headers['Authorization']}"
175
+ [self.oauth_signature == test_signature, test_request.signature_base_string]
176
+ end
177
+ end
178
+ end
179
+ end