change to a more generic lti outcome service url scheme
refs #5892 according to the spec, this url shouldn't change for each user/assignment, so we've refactored so that only the tool id is in the url. This involved some refactoring to include the user, course and assignment in the sourcedid, rather than just the signature of those values, and using those values to look up the objects. As a consequence we now return "unsupported" rather than a 401 error if the sourcedid is incorrect, which I think is more correct according to the spec anyway. Change-Id: I612d48cbd63b527d9f9209b858ebeca28ebe9202 Reviewed-on: https://gerrit.instructure.com/6699 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Brian Whitmer <brian@instructure.com>
This commit is contained in:
parent
e987dc2452
commit
0e72be8bcf
|
@ -826,7 +826,7 @@ class ApplicationController < ActionController::Base
|
||||||
@return_url = named_context_url(@context, :context_external_tool_finished_url, @tool.id, :include_host => true)
|
@return_url = named_context_url(@context, :context_external_tool_finished_url, @tool.id, :include_host => true)
|
||||||
@launch = BasicLTI::ToolLaunch.new(:url => @resource_url, :tool => @tool, :user => @current_user, :context => @context, :link_code => @opaque_id, :return_url => @return_url)
|
@launch = BasicLTI::ToolLaunch.new(:url => @resource_url, :tool => @tool, :user => @current_user, :context => @context, :link_code => @opaque_id, :return_url => @return_url)
|
||||||
if @tag.context.is_a?(Assignment) && @context.students.include?(@current_user)
|
if @tag.context.is_a?(Assignment) && @context.students.include?(@current_user)
|
||||||
@launch.for_assignment!(@tag.context, lti_grade_passback_api_url(@context, @tag.context, @current_user))
|
@launch.for_assignment!(@tag.context, lti_grade_passback_api_url(@tool))
|
||||||
end
|
end
|
||||||
@tool_settings = @launch.generate
|
@tool_settings = @launch.generate
|
||||||
render :template => 'external_tools/tool_show'
|
render :template => 'external_tools/tool_show'
|
||||||
|
|
|
@ -19,39 +19,29 @@
|
||||||
require 'oauth/request_proxy/action_controller_request'
|
require 'oauth/request_proxy/action_controller_request'
|
||||||
|
|
||||||
class LtiApiController < ApplicationController
|
class LtiApiController < ApplicationController
|
||||||
|
|
||||||
class Unauthorized < Exception; end
|
|
||||||
|
|
||||||
def grade_passback
|
def grade_passback
|
||||||
require_context
|
|
||||||
|
|
||||||
# load the external tool to grab the key and secret
|
# load the external tool to grab the key and secret
|
||||||
@assignment = @context.assignments.find(params[:assignment_id])
|
@tool = ContextExternalTool.find(params[:tool_id])
|
||||||
@user = @context.students.find(params[:id])
|
|
||||||
tag = @assignment.external_tool_tag
|
|
||||||
@tool = ContextExternalTool.find_external_tool(tag.url, @context) if tag
|
|
||||||
|
|
||||||
raise(Unauthorized, "LTI tool not found") unless @tool
|
# verify the request oauth signature, timestamp and nonce
|
||||||
|
|
||||||
# verify the request oauth signature
|
|
||||||
begin
|
begin
|
||||||
@signature = OAuth::Signature.build(request, :consumer_secret => @tool.shared_secret)
|
@signature = OAuth::Signature.build(request, :consumer_secret => @tool.shared_secret)
|
||||||
@signature.verify() or raise OAuth::Unauthorized
|
@signature.verify() or raise OAuth::Unauthorized
|
||||||
rescue OAuth::Signature::UnknownSignatureMethod,
|
rescue OAuth::Signature::UnknownSignatureMethod,
|
||||||
OAuth::Unauthorized
|
OAuth::Unauthorized
|
||||||
raise Unauthorized, "Invalid authorization header"
|
raise BasicLTI::BasicOutcomes::Unauthorized, "Invalid authorization header"
|
||||||
end
|
end
|
||||||
|
|
||||||
timestamp = Time.zone.at(@signature.request.timestamp.to_i)
|
timestamp = Time.zone.at(@signature.request.timestamp.to_i)
|
||||||
# 90 minutes is suggested by the LTI spec
|
# 90 minutes is suggested by the LTI spec
|
||||||
allowed_delta = Setting.get_cached('oauth.allowed_timestamp_delta', 90.minutes.to_s).to_i
|
allowed_delta = Setting.get_cached('oauth.allowed_timestamp_delta', 90.minutes.to_s).to_i
|
||||||
if timestamp < allowed_delta.ago || timestamp > allowed_delta.from_now
|
if timestamp < allowed_delta.ago || timestamp > allowed_delta.from_now
|
||||||
raise Unauthorized, "Timestamp too old, request has expired"
|
raise BasicLTI::BasicOutcomes::Unauthorized, "Timestamp too old or too far in the future, request has expired"
|
||||||
end
|
end
|
||||||
|
|
||||||
nonce = @signature.request.nonce
|
nonce = @signature.request.nonce
|
||||||
unless Canvas::Redis.lock("nonce:#{@tool.asset_string}:#{nonce}", allowed_delta)
|
unless Canvas::Redis.lock("nonce:#{@tool.asset_string}:#{nonce}", allowed_delta)
|
||||||
raise Unauthorized, "Duplicate nonce detected"
|
raise BasicLTI::BasicOutcomes::Unauthorized, "Duplicate nonce detected"
|
||||||
end
|
end
|
||||||
|
|
||||||
if request.content_type != "application/xml"
|
if request.content_type != "application/xml"
|
||||||
|
@ -60,10 +50,10 @@ class LtiApiController < ApplicationController
|
||||||
|
|
||||||
xml = Nokogiri::XML.parse(request.body)
|
xml = Nokogiri::XML.parse(request.body)
|
||||||
|
|
||||||
lti_response = BasicLTI::BasicOutcomes.process_request(@tool, @context, @assignment, @user, xml)
|
lti_response = BasicLTI::BasicOutcomes.process_request(@tool, xml)
|
||||||
render :text => lti_response.to_xml, :content_type => 'application/xml'
|
render :text => lti_response.to_xml, :content_type => 'application/xml'
|
||||||
|
|
||||||
rescue Unauthorized => e
|
rescue BasicLTI::BasicOutcomes::Unauthorized => e
|
||||||
render :text => e.to_s, :content_type => 'application/xml', :status => 401
|
render :text => e.to_s, :content_type => 'application/xml', :status => 401
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -740,7 +740,7 @@ ActionController::Routing::Routes.draw do |map|
|
||||||
map.oauth2_token 'login/oauth2/token',:controller => 'pseudonym_sessions', :action => 'oauth2_token', :conditions => { :method => :post }
|
map.oauth2_token 'login/oauth2/token',:controller => 'pseudonym_sessions', :action => 'oauth2_token', :conditions => { :method => :post }
|
||||||
|
|
||||||
ApiRouteSet.route(map, "/api/lti/v1") do |lti|
|
ApiRouteSet.route(map, "/api/lti/v1") do |lti|
|
||||||
lti.post "courses/:course_id/assignments/:assignment_id/submissions/:id", :controller => :lti_api, :action => :grade_passback, :path_name => "lti_grade_passback_api"
|
lti.post "tools/:tool_id/grade_passback", :controller => :lti_api, :action => :grade_passback, :path_name => "lti_grade_passback_api"
|
||||||
end
|
end
|
||||||
|
|
||||||
map.resources :equation_images, :only => :show
|
map.resources :equation_images, :only => :show
|
||||||
|
|
|
@ -66,7 +66,7 @@ module BasicLTI
|
||||||
end
|
end
|
||||||
|
|
||||||
def for_assignment!(assignment, outcome_service_url)
|
def for_assignment!(assignment, outcome_service_url)
|
||||||
hash['lis_result_sourcedid'] = BasicLTI::BasicOutcomes.result_source_id(tool, context, assignment, user)
|
hash['lis_result_sourcedid'] = BasicLTI::BasicOutcomes.encode_source_id(tool, context, assignment, user)
|
||||||
hash['lis_outcome_service_url'] = outcome_service_url
|
hash['lis_outcome_service_url'] = outcome_service_url
|
||||||
if tool.public?
|
if tool.public?
|
||||||
hash['custom_canvas_assignment_id'] = assignment.id
|
hash['custom_canvas_assignment_id'] = assignment.id
|
||||||
|
|
|
@ -1,16 +1,38 @@
|
||||||
module BasicLTI::BasicOutcomes
|
module BasicLTI::BasicOutcomes
|
||||||
|
class Unauthorized < Exception; end
|
||||||
|
|
||||||
# this is the lis_result_sourcedid field in the launch, and the
|
# this is the lis_result_sourcedid field in the launch, and the
|
||||||
# sourcedGUID/sourcedId in BLTI basic outcome requests.
|
# sourcedGUID/sourcedId in BLTI basic outcome requests.
|
||||||
# it's a secure signature of the (tool, course, assignment, user). Combined with
|
# it's a secure signature of the (tool, course, assignment, user). Combined with
|
||||||
# the pre-determined shared secret that the tool signs requests with, this
|
# the pre-determined shared secret that the tool signs requests with, this
|
||||||
# ensures that only this launch of the tool can modify the score.
|
# ensures that only this launch of the tool can modify the score.
|
||||||
def self.result_source_id(tool, course, assignment, user)
|
def self.encode_source_id(tool, course, assignment, user)
|
||||||
Canvas::Security.hmac_sha1("#{tool.id}-#{course.id}-#{assignment.id}-#{user.id}")
|
payload = [tool.id, course.id, assignment.id, user.id].join('-')
|
||||||
|
"#{payload}-#{Canvas::Security.hmac_sha1(payload)}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.process_request(tool, course, assignment, user, xml)
|
SOURCE_ID_REGEX = %r{^(\d+)-(\d+)-(\d+)-(\d+)-(\w+)$}
|
||||||
|
|
||||||
|
def self.decode_source_id(sourceid)
|
||||||
|
md = sourceid.match(SOURCE_ID_REGEX)
|
||||||
|
return false unless md
|
||||||
|
new_encoding = [md[1], md[2], md[3], md[4]].join('-')
|
||||||
|
return false unless Canvas::Security.hmac_sha1(new_encoding) == md[5]
|
||||||
|
tool = ContextExternalTool.find(md[1])
|
||||||
|
course = Course.find(md[2])
|
||||||
|
assignment = course.assignments.active.find(md[3])
|
||||||
|
user = course.students.active.find(md[4])
|
||||||
|
tag = assignment.external_tool_tag
|
||||||
|
if !tag || tool != ContextExternalTool.find_external_tool(tag.url, course)
|
||||||
|
return false # assignment settings have changed, this tool is no longer active
|
||||||
|
end
|
||||||
|
return course, assignment, user
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.process_request(tool, xml)
|
||||||
res = LtiResponse.new(xml)
|
res = LtiResponse.new(xml)
|
||||||
unless self.handle_request(tool, course, assignment, user, xml, res)
|
|
||||||
|
unless self.handle_request(tool, xml, res)
|
||||||
res.code_major = 'unsupported'
|
res.code_major = 'unsupported'
|
||||||
end
|
end
|
||||||
return res
|
return res
|
||||||
|
@ -18,12 +40,14 @@ module BasicLTI::BasicOutcomes
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
def self.handle_request(tool, course, assignment, user, xml, res)
|
def self.handle_request(tool, xml, res)
|
||||||
# verify the lis_result_sourcedid param, which will be a canvas-signed
|
# verify the lis_result_sourcedid param, which will be a canvas-signed
|
||||||
# tuple of (course, assignment, user) to ensure that only this launch of
|
# tuple of (course, assignment, user) to ensure that only this launch of
|
||||||
# the tool is attempting to modify this data.
|
# the tool is attempting to modify this data.
|
||||||
source_id = xml.at_css('imsx_POXBody sourcedGUID > sourcedId').try(:content)
|
source_id = xml.at_css('imsx_POXBody sourcedGUID > sourcedId').try(:content)
|
||||||
unless source_id && source_id == BasicLTI::BasicOutcomes.result_source_id(tool, course, assignment, user)
|
course, assignment, user = self.decode_source_id(source_id) if source_id
|
||||||
|
|
||||||
|
unless course && assignment && user
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -31,7 +31,7 @@ describe LtiApiController, :type => :integration do
|
||||||
end
|
end
|
||||||
|
|
||||||
def make_call(opts = {})
|
def make_call(opts = {})
|
||||||
opts['path'] ||= "/api/lti/v1/courses/#{@course.id}/assignments/#{@assignment.id}/submissions/#{@student.id}"
|
opts['path'] ||= "/api/lti/v1/tools/#{@tool.id}/grade_passback"
|
||||||
opts['key'] ||= @tool.consumer_key
|
opts['key'] ||= @tool.consumer_key
|
||||||
opts['secret'] ||= @tool.shared_secret
|
opts['secret'] ||= @tool.shared_secret
|
||||||
opts['content-type'] ||= 'application/xml'
|
opts['content-type'] ||= 'application/xml'
|
||||||
|
@ -60,7 +60,7 @@ describe LtiApiController, :type => :integration do
|
||||||
end
|
end
|
||||||
|
|
||||||
def replace_result(score, sourceid = nil)
|
def replace_result(score, sourceid = nil)
|
||||||
sourceid ||= BasicLTI::BasicOutcomes.result_source_id(@tool, @course, @assignment, @student)
|
sourceid ||= BasicLTI::BasicOutcomes.encode_source_id(@tool, @course, @assignment, @student)
|
||||||
body = %{
|
body = %{
|
||||||
<?xml version = "1.0" encoding = "UTF-8"?>
|
<?xml version = "1.0" encoding = "UTF-8"?>
|
||||||
<imsx_POXEnvelopeRequest xmlns = "http://www.imsglobal.org/lis/oms1p0/pox">
|
<imsx_POXEnvelopeRequest xmlns = "http://www.imsglobal.org/lis/oms1p0/pox">
|
||||||
|
@ -90,7 +90,7 @@ describe LtiApiController, :type => :integration do
|
||||||
end
|
end
|
||||||
|
|
||||||
def read_result(sourceid = nil)
|
def read_result(sourceid = nil)
|
||||||
sourceid ||= BasicLTI::BasicOutcomes.result_source_id(@tool, @course, @assignment, @student)
|
sourceid ||= BasicLTI::BasicOutcomes.encode_source_id(@tool, @course, @assignment, @student)
|
||||||
body = %{
|
body = %{
|
||||||
<?xml version = "1.0" encoding = "UTF-8"?>
|
<?xml version = "1.0" encoding = "UTF-8"?>
|
||||||
<imsx_POXEnvelopeRequest xmlns = "http://www.imsglobal.org/lis/oms1p0/pox">
|
<imsx_POXEnvelopeRequest xmlns = "http://www.imsglobal.org/lis/oms1p0/pox">
|
||||||
|
@ -114,7 +114,7 @@ describe LtiApiController, :type => :integration do
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete_result(sourceid = nil)
|
def delete_result(sourceid = nil)
|
||||||
sourceid ||= BasicLTI::BasicOutcomes.result_source_id(@tool, @course, @assignment, @student)
|
sourceid ||= BasicLTI::BasicOutcomes.encode_source_id(@tool, @course, @assignment, @student)
|
||||||
body = %{
|
body = %{
|
||||||
<?xml version = "1.0" encoding = "UTF-8"?>
|
<?xml version = "1.0" encoding = "UTF-8"?>
|
||||||
<imsx_POXEnvelopeRequest xmlns = "http://www.imsglobal.org/lis/oms1p0/pox">
|
<imsx_POXEnvelopeRequest xmlns = "http://www.imsglobal.org/lis/oms1p0/pox">
|
||||||
|
@ -259,7 +259,7 @@ describe LtiApiController, :type => :integration do
|
||||||
tag.content_type = 'ContextExternalTool'
|
tag.content_type = 'ContextExternalTool'
|
||||||
tag.save!
|
tag.save!
|
||||||
make_call('body' => replace_result('0.5'))
|
make_call('body' => replace_result('0.5'))
|
||||||
response.status.should == "401 Unauthorized"
|
check_failure
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should be unsupported if the assignment switched to a new tool with the same shared secret" do
|
it "should be unsupported if the assignment switched to a new tool with the same shared secret" do
|
||||||
|
@ -277,7 +277,7 @@ describe LtiApiController, :type => :integration do
|
||||||
@assignment.update_attributes(:submission_types => 'online_upload')
|
@assignment.update_attributes(:submission_types => 'online_upload')
|
||||||
@assignment.external_tool_tag.destroy!
|
@assignment.external_tool_tag.destroy!
|
||||||
make_call('body' => replace_result('0.5'))
|
make_call('body' => replace_result('0.5'))
|
||||||
response.status.should == "401 Unauthorized"
|
check_failure
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should verify the sourcedid is correct for this tool launch" do
|
it "should verify the sourcedid is correct for this tool launch" do
|
||||||
|
|
|
@ -53,8 +53,8 @@ describe "External Tools" do
|
||||||
get "/courses/#{@course.id}/assignments/#{@assignment.id}"
|
get "/courses/#{@course.id}/assignments/#{@assignment.id}"
|
||||||
response.should be_success
|
response.should be_success
|
||||||
doc = Nokogiri::HTML.parse(response.body)
|
doc = Nokogiri::HTML.parse(response.body)
|
||||||
doc.at_css('form#tool_form input#lis_result_sourcedid')['value'].should == BasicLTI::BasicOutcomes.result_source_id(@tool, @course, @assignment, @user)
|
doc.at_css('form#tool_form input#lis_result_sourcedid')['value'].should == BasicLTI::BasicOutcomes.encode_source_id(@tool, @course, @assignment, @user)
|
||||||
doc.at_css('form#tool_form input#lis_outcome_service_url')['value'].should == lti_grade_passback_api_url(@course, @assignment, @user)
|
doc.at_css('form#tool_form input#lis_outcome_service_url')['value'].should == lti_grade_passback_api_url(@tool)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "should not include outcome service params when viewing as teacher" do
|
it "should not include outcome service params when viewing as teacher" do
|
||||||
|
|
|
@ -194,7 +194,7 @@ describe BasicLTI do
|
||||||
assignment_model(:submission_types => "external_tool", :course => @course)
|
assignment_model(:submission_types => "external_tool", :course => @course)
|
||||||
launch.for_assignment!(@assignment, "/my/test/url")
|
launch.for_assignment!(@assignment, "/my/test/url")
|
||||||
hash = launch.generate
|
hash = launch.generate
|
||||||
hash['lis_result_sourcedid'].should == BasicLTI::BasicOutcomes.result_source_id(@tool, @course, @assignment, @user)
|
hash['lis_result_sourcedid'].should == BasicLTI::BasicOutcomes.encode_source_id(@tool, @course, @assignment, @user)
|
||||||
hash['lis_outcome_service_url'].should == "/my/test/url"
|
hash['lis_outcome_service_url'].should == "/my/test/url"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue