lti2 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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