Revert "Revert "Accept signed JWT tokens as the sourcedid for basic outcomes""
This reverts commit 50e604a8a9
.
Change-Id: Ib9b403abe9dbfa5ba1468bd86317d210687b33c0
Reviewed-on: https://gerrit.instructure.com/133324
QA-Review: August Thornton <august@instructure.com>
Reviewed-by: Andrew Butterfield <abutterfield@instructure.com>
Tested-by: Jenkins
Product-Review: Weston Dransfield <wdransfield@instructure.com>
This commit is contained in:
parent
90f2530420
commit
278682f152
|
@ -113,8 +113,12 @@ module Lti
|
|||
# ensures that only this launch of the tool can modify the score.
|
||||
def encode_source_id(assignment)
|
||||
@tool.shard.activate do
|
||||
payload = [@tool.id, @context.id, assignment.id, @user.id].join('-')
|
||||
"#{payload}-#{Canvas::Security.hmac_sha1(payload)}"
|
||||
if @root_account.feature_enabled?(:encrypted_sourcedids)
|
||||
BasicLTI::Sourcedid.new(@tool, @context, assignment, @user).to_s
|
||||
else
|
||||
payload = [@tool.id, @context.id, assignment.id, @user.id].join('-')
|
||||
"#{payload}-#{Canvas::Security.hmac_sha1(payload)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -33,35 +33,13 @@ module BasicLTI
|
|||
end
|
||||
end
|
||||
|
||||
class InvalidSourceId < StandardError
|
||||
end
|
||||
|
||||
SOURCE_ID_REGEX = %r{^(\d+)-(\d+)-(\d+)-(\d+)-(\w+)$}
|
||||
|
||||
def self.decode_source_id(tool, sourceid)
|
||||
tool.shard.activate do
|
||||
raise InvalidSourceId, 'Invalid sourcedid' if sourceid.blank?
|
||||
md = sourceid.match(SOURCE_ID_REGEX)
|
||||
raise InvalidSourceId, 'Invalid sourcedid' unless md
|
||||
new_encoding = [md[1], md[2], md[3], md[4]].join('-')
|
||||
raise InvalidSourceId, 'Invalid signature' unless Canvas::Security.
|
||||
verify_hmac_sha1(md[5], new_encoding, key: tool.shard.settings[:encryption_key])
|
||||
|
||||
raise InvalidSourceId, 'Tool is invalid' unless tool.id == md[1].to_i
|
||||
course = Course.active.where(id: md[2]).first
|
||||
raise InvalidSourceId, 'Course is invalid' unless course
|
||||
|
||||
user = course.student_enrollments.active.where(user_id: md[4]).first.try(:user)
|
||||
raise InvalidSourceId, 'User is no longer in course' unless user
|
||||
|
||||
assignment = course.assignments.active.where(id: md[3]).first
|
||||
raise InvalidSourceId, 'Assignment is invalid' unless assignment
|
||||
|
||||
tag = assignment.external_tool_tag if assignment
|
||||
raise InvalidSourceId, 'Assignment is no longer associated with this tool' unless tag and tool.
|
||||
matches_url?(tag.url, false) and tool.workflow_state != 'deleted'
|
||||
|
||||
return course, assignment, user
|
||||
sourcedid = BasicLTI::Sourcedid.load!(sourceid)
|
||||
raise BasicLTI::Errors::InvalidSourceId, 'Tool is invalid' unless tool == sourcedid.tool
|
||||
return sourcedid.course, sourcedid.assignment, sourcedid.user
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -183,7 +161,7 @@ module BasicLTI
|
|||
|
||||
begin
|
||||
course, assignment, user = BasicLTI::BasicOutcomes.decode_source_id(tool, source_id)
|
||||
rescue InvalidSourceId => e
|
||||
rescue Errors::InvalidSourceId => e
|
||||
self.code_major = 'failure'
|
||||
self.description = e.to_s
|
||||
self.body = "<#{operation_ref_identifier}Response />"
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
#
|
||||
# Copyright (C) 2017 - present Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
module BasicLTI
|
||||
module Errors
|
||||
class InvalidSourceId < StandardError
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,110 @@
|
|||
#
|
||||
# Copyright (C) 2017 - present Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
module BasicLTI
|
||||
class Sourcedid
|
||||
SOURCE_ID_REGEX = %r{^(\d+)-(\d+)-(\d+)-(\d+)-(\w+)$}
|
||||
|
||||
attr_reader :tool, :course, :assignment, :user
|
||||
|
||||
def initialize(tool, course, assignment, user)
|
||||
@tool, @course, @assignment, @user = tool, course, assignment, user
|
||||
end
|
||||
|
||||
def to_s
|
||||
crypted_token = Canvas::Security.create_encrypted_jwt(
|
||||
jwt_payload,
|
||||
self.class.signing_secret,
|
||||
self.class.encryption_secret
|
||||
)
|
||||
Canvas::Security.base64_encode(crypted_token)
|
||||
end
|
||||
|
||||
def jwt_payload
|
||||
{
|
||||
iss: "Canvas",
|
||||
aud: ["Instructure"],
|
||||
iat: Time.zone.now.to_i,
|
||||
tool_id: tool.id,
|
||||
course_id: course.id,
|
||||
assignment_id: assignment.id,
|
||||
user_id: user.id,
|
||||
}
|
||||
end
|
||||
private :jwt_payload
|
||||
|
||||
def validate!
|
||||
raise Errors::InvalidSourceId, 'Course is invalid' unless course
|
||||
raise Errors::InvalidSourceId, 'User is no longer in course' unless user
|
||||
raise Errors::InvalidSourceId, 'Assignment is invalid' unless assignment
|
||||
|
||||
tag = assignment.external_tool_tag
|
||||
raise Errors::InvalidSourceId, 'Assignment is no longer associated with this tool' unless tag && tool.
|
||||
matches_url?(tag.url, false) && tool.workflow_state != 'deleted'
|
||||
end
|
||||
|
||||
def self.load!(sourcedid_string)
|
||||
raise Errors::InvalidSourceId, 'Invalid sourcedid' if sourcedid_string.blank?
|
||||
token = load_from_legacy_sourcedid!(sourcedid_string) ||
|
||||
token_from_sourcedid!(sourcedid_string)
|
||||
|
||||
tool = ContextExternalTool.find_by(id: token[:tool_id])
|
||||
course = Course.active.find_by(id: token[:course_id])
|
||||
if course
|
||||
user = course.student_enrollments.active.find_by(user_id: token[:user_id])&.user
|
||||
assignment = course.assignments.active.find_by(id: token[:assignment_id])
|
||||
end
|
||||
|
||||
sourcedid = self.new(tool, course, assignment, user)
|
||||
sourcedid.validate!
|
||||
sourcedid
|
||||
end
|
||||
|
||||
def self.load_from_legacy_sourcedid!(sourcedid)
|
||||
token = nil
|
||||
md = sourcedid.match(SOURCE_ID_REGEX)
|
||||
if md
|
||||
tool = ContextExternalTool.find_by(id: md[1])
|
||||
raise Errors::InvalidSourceId, 'Tool is invalid' unless tool
|
||||
new_encoding = [md[1], md[2], md[3], md[4]].join('-')
|
||||
raise Errors::InvalidSourceId, 'Invalid signature' unless Canvas::Security.
|
||||
verify_hmac_sha1(md[5], new_encoding, key: tool.shard.settings[:encryption_key])
|
||||
token = { tool_id: md[1].to_i, course_id: md[2], assignment_id: md[3], user_id: md[4] }
|
||||
end
|
||||
token
|
||||
end
|
||||
|
||||
def self.token_from_sourcedid!(sourcedid)
|
||||
Canvas::Security.decrypt_services_jwt(
|
||||
Canvas::Security.base64_decode(sourcedid),
|
||||
signing_secret,
|
||||
encryption_secret
|
||||
)
|
||||
rescue JSON::JWT::InvalidFormat
|
||||
raise Errors::InvalidSourceId, 'Invalid sourcedid'
|
||||
end
|
||||
|
||||
def self.signing_secret
|
||||
Canvas::DynamicSettings.find()["lti-signing-secret"]
|
||||
end
|
||||
|
||||
def self.encryption_secret
|
||||
Canvas::DynamicSettings.find()["lti-encryption-secret"]
|
||||
end
|
||||
end
|
||||
end
|
|
@ -557,6 +557,12 @@ END
|
|||
state: "allowed",
|
||||
root_opt_in: true
|
||||
},
|
||||
'encrypted_sourcedids' => {
|
||||
display_name: -> { I18n.t('Encrypted Sourcedids for Basic Outcomes') },
|
||||
description: -> { I18n.t('If enabled, Sourcedids used by Canvas for Basic Outcomes will be encrypted.') },
|
||||
applies_to: 'RootAccount',
|
||||
state: 'allowed'
|
||||
}
|
||||
)
|
||||
|
||||
def self.definitions
|
||||
|
|
|
@ -39,6 +39,11 @@ describe LtiApiController, type: :request do
|
|||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(BasicLTI::Sourcedid).to receive(:encryption_secret) {'encryption-secret-5T14NjaTbcYjc4'}
|
||||
allow(BasicLTI::Sourcedid).to receive(:signing_secret) {'signing-secret-vp04BNqApwdwUYPUI'}
|
||||
end
|
||||
|
||||
|
||||
def lti_api_call(method, path, body = nil)
|
||||
consumer = OAuth::Consumer.new(tool.consumer_key, tool.shared_secret, :site => "https://www.example.com", :signature_method => "HMAC-SHA1")
|
||||
|
|
|
@ -118,6 +118,11 @@ describe ExternalToolsController, type: :request do
|
|||
end
|
||||
|
||||
context 'assessment launch' do
|
||||
before do
|
||||
allow(BasicLTI::Sourcedid).to receive(:encryption_secret) {'encryption-secret-5T14NjaTbcYjc4'}
|
||||
allow(BasicLTI::Sourcedid).to receive(:signing_secret) {'signing-secret-vp04BNqApwdwUYPUI'}
|
||||
end
|
||||
|
||||
it 'returns a bad request response if there is no assignment_id' do
|
||||
params = {id: tool.id.to_s, launch_type: 'assessment'}
|
||||
code = get_raw_sessionless_launch_url(@course, 'course', params)
|
||||
|
|
|
@ -439,6 +439,8 @@ describe AssignmentsController do
|
|||
Setting.set('enable_page_views', 'db')
|
||||
@old_thread_context = Thread.current[:context]
|
||||
Thread.current[:context] = { request_id: SecureRandom.uuid }
|
||||
allow(BasicLTI::Sourcedid).to receive(:encryption_secret) {'encryption-secret-5T14NjaTbcYjc4'}
|
||||
allow(BasicLTI::Sourcedid).to receive(:signing_secret) {'signing-secret-vp04BNqApwdwUYPUI'}
|
||||
end
|
||||
|
||||
after do
|
||||
|
|
|
@ -1227,6 +1227,11 @@ describe ExternalToolsController do
|
|||
end
|
||||
|
||||
describe "'GET 'generate_sessionless_launch'" do
|
||||
before do
|
||||
allow(BasicLTI::Sourcedid).to receive(:encryption_secret) {'encryption-secret-5T14NjaTbcYjc4'}
|
||||
allow(BasicLTI::Sourcedid).to receive(:signing_secret) {'signing-secret-vp04BNqApwdwUYPUI'}
|
||||
end
|
||||
|
||||
it "generates a sessionless launch" do
|
||||
@tool = new_valid_tool(@course)
|
||||
user_session(@user)
|
||||
|
|
|
@ -32,6 +32,11 @@ describe LtiApiController, type: :request do
|
|||
tag.save!
|
||||
end
|
||||
|
||||
before do
|
||||
allow(BasicLTI::Sourcedid).to receive(:encryption_secret) {'encryption-secret-5T14NjaTbcYjc4'}
|
||||
allow(BasicLTI::Sourcedid).to receive(:signing_secret) {'signing-secret-vp04BNqApwdwUYPUI'}
|
||||
end
|
||||
|
||||
def check_error_response(message, check_generated_sig=true)
|
||||
expect(response.body.strip).to_not be_empty, "Should not have an empty response body"
|
||||
|
||||
|
@ -266,8 +271,6 @@ XML
|
|||
desc = xml.at_css('imsx_description').content.match(/(?<description>.+)\n\[EID_(?<error_report>[^\]]+)\]/)
|
||||
expect(desc[:description]).to eq error_message if error_message
|
||||
expect(desc[:error_report]).to_not be_empty
|
||||
|
||||
|
||||
end
|
||||
|
||||
def check_success
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
#
|
||||
# Copyright (C) 2017 - present Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
module Factories
|
||||
def external_tool_model(context: nil)
|
||||
context ||= course_model
|
||||
context.context_external_tools.create(
|
||||
:name => "a",
|
||||
:url => "http://google.com",
|
||||
:consumer_key => '12345',
|
||||
:shared_secret => 'secret'
|
||||
)
|
||||
end
|
||||
end
|
|
@ -23,6 +23,8 @@ require 'nokogiri'
|
|||
describe "External Tools" do
|
||||
describe "Assignments" do
|
||||
before do
|
||||
allow(BasicLTI::Sourcedid).to receive(:encryption_secret) {'encryption-secret-5T14NjaTbcYjc4'}
|
||||
allow(BasicLTI::Sourcedid).to receive(:signing_secret) {'signing-secret-vp04BNqApwdwUYPUI'}
|
||||
course_factory(active_all: true)
|
||||
assignment_model(:course => @course, :submission_types => "external_tool", :points_possible => 25)
|
||||
@tool = @course.context_external_tools.create!(:shared_secret => 'test_secret', :consumer_key => 'test_key', :name => 'my grade passback test tool', :domain => 'example.com')
|
||||
|
@ -50,16 +52,17 @@ describe "External Tools" do
|
|||
end
|
||||
|
||||
it "should include outcome service params when viewing as student" do
|
||||
allow_any_instance_of(Account).to receive(:feature_enabled?) { false }
|
||||
allow_any_instance_of(Account).to receive(:feature_enabled?).with(:encrypted_sourcedids).and_return(true)
|
||||
allow(Canvas::Security).to receive(:create_encrypted_jwt) { 'an.encrypted.jwt' }
|
||||
student_in_course(:course => @course, :active_all => true)
|
||||
user_session(@user)
|
||||
allow(Canvas::Security).to receive(:hmac_sha1).and_return('some_sha')
|
||||
payload = [@tool.id, @course.id, @assignment.id, @user.id].join('-')
|
||||
|
||||
get "/courses/#{@course.id}/assignments/#{@assignment.id}"
|
||||
expect(response).to be_success
|
||||
doc = Nokogiri::HTML.parse(response.body)
|
||||
|
||||
expect(doc.at_css('form#tool_form input#lis_result_sourcedid')['value']).to eq "#{payload}-some_sha"
|
||||
expect(doc.at_css('form#tool_form input#lis_result_sourcedid')['value']).to eq BasicLTI::Sourcedid.new(@tool, @course, @assignment, @user).to_s
|
||||
expect(doc.at_css('form#tool_form input#lis_outcome_service_url')['value']).to eq lti_grade_passback_api_url(@tool)
|
||||
expect(doc.at_css('form#tool_form input#ext_ims_lis_basic_outcome_url')['value']).to eq blti_legacy_grade_passback_api_url(@tool)
|
||||
end
|
||||
|
|
|
@ -109,63 +109,56 @@ describe BasicLTI::BasicOutcomes do
|
|||
end
|
||||
|
||||
describe ".decode_source_id" do
|
||||
|
||||
it 'successfully decodes a source_id' do
|
||||
expect(described_class.decode_source_id(tool, source_id)).to eq [@course, assignment, @user]
|
||||
end
|
||||
|
||||
it 'throws Invalid sourcedid if sourcedid is nil' do
|
||||
expect{described_class.decode_source_id(tool, nil)}.
|
||||
to raise_error(BasicLTI::BasicOutcomes::InvalidSourceId, 'Invalid sourcedid')
|
||||
to raise_error(BasicLTI::Errors::InvalidSourceId, 'Invalid sourcedid')
|
||||
end
|
||||
|
||||
it 'throws Invalid sourcedid if sourcedid is empty' do
|
||||
expect{described_class.decode_source_id(tool, "")}.
|
||||
to raise_error(BasicLTI::BasicOutcomes::InvalidSourceId, 'Invalid sourcedid')
|
||||
end
|
||||
|
||||
it 'throws Invalid sourcedid if no signature' do
|
||||
missing_signature = source_id.split('-')[0..3].join('-')
|
||||
expect{described_class.decode_source_id(tool, missing_signature)}.
|
||||
to raise_error(BasicLTI::BasicOutcomes::InvalidSourceId, 'Invalid sourcedid')
|
||||
to raise_error(BasicLTI::Errors::InvalidSourceId, 'Invalid sourcedid')
|
||||
end
|
||||
|
||||
it 'throws Invalid signature if the signature is invalid' do
|
||||
bad_signature = source_id.split('-')[0..3].join('-') + '-asb9dksld9k3'
|
||||
expect{described_class.decode_source_id(tool, bad_signature)}.
|
||||
to raise_error(BasicLTI::BasicOutcomes::InvalidSourceId, 'Invalid signature')
|
||||
to raise_error(BasicLTI::Errors::InvalidSourceId, 'Invalid signature')
|
||||
end
|
||||
|
||||
it "throws Tool is invalid if the tool doesn't match" do
|
||||
it "throws 'Tool is invalid' if the tool doesn't match" do
|
||||
t = @course.context_external_tools.
|
||||
create(:name => "b", :url => "http://google.com", :consumer_key => '12345', :shared_secret => 'secret')
|
||||
create(:name => "b", :url => "http://google.com", :consumer_key => '12345', :shared_secret => 'secret')
|
||||
expect{described_class.decode_source_id(tool, gen_source_id(t: t))}.
|
||||
to raise_error(BasicLTI::BasicOutcomes::InvalidSourceId, 'Tool is invalid')
|
||||
to raise_error(BasicLTI::Errors::InvalidSourceId, 'Tool is invalid')
|
||||
end
|
||||
|
||||
it "throws Course is invalid if the course doesn't match" do
|
||||
@course.workflow_state = 'deleted'
|
||||
@course.save!
|
||||
expect{described_class.decode_source_id(tool, source_id)}.
|
||||
to raise_error(BasicLTI::BasicOutcomes::InvalidSourceId, 'Course is invalid')
|
||||
to raise_error(BasicLTI::Errors::InvalidSourceId, 'Course is invalid')
|
||||
end
|
||||
|
||||
it "throws User is no longer in course isuser enrollment is missing" do
|
||||
@user.enrollments.destroy_all
|
||||
expect{described_class.decode_source_id(tool, source_id)}.
|
||||
to raise_error(BasicLTI::BasicOutcomes::InvalidSourceId, 'User is no longer in course')
|
||||
to raise_error(BasicLTI::Errors::InvalidSourceId, 'User is no longer in course')
|
||||
end
|
||||
|
||||
it "throws Assignment is invalid if the Addignment doesn't match" do
|
||||
assignment.destroy
|
||||
expect{described_class.decode_source_id(tool, source_id)}.
|
||||
to raise_error(BasicLTI::BasicOutcomes::InvalidSourceId, 'Assignment is invalid')
|
||||
to raise_error(BasicLTI::Errors::InvalidSourceId, 'Assignment is invalid')
|
||||
end
|
||||
|
||||
it "throws Assignment is no longer associated with this tool if tool is deleted" do
|
||||
tool.destroy
|
||||
expect{described_class.decode_source_id(tool, source_id)}.
|
||||
to raise_error(BasicLTI::BasicOutcomes::InvalidSourceId, 'Assignment is no longer associated with this tool')
|
||||
to raise_error(BasicLTI::Errors::InvalidSourceId, 'Assignment is no longer associated with this tool')
|
||||
end
|
||||
|
||||
it "throws Assignment is no longer associated with this tool if tool doesn't match the url" do
|
||||
|
@ -173,15 +166,39 @@ describe BasicLTI::BasicOutcomes do
|
|||
tag.url = 'example.com'
|
||||
tag.save!
|
||||
expect{described_class.decode_source_id(tool, source_id)}.
|
||||
to raise_error(BasicLTI::BasicOutcomes::InvalidSourceId, 'Assignment is no longer associated with this tool')
|
||||
to raise_error(BasicLTI::Errors::InvalidSourceId, 'Assignment is no longer associated with this tool')
|
||||
end
|
||||
|
||||
it "throws Assignment is no longer associated with this tool if tag is missing" do
|
||||
assignment.external_tool_tag.delete
|
||||
expect{described_class.decode_source_id(tool, source_id)}.
|
||||
to raise_error(BasicLTI::BasicOutcomes::InvalidSourceId, 'Assignment is no longer associated with this tool')
|
||||
to raise_error(BasicLTI::Errors::InvalidSourceId, 'Assignment is no longer associated with this tool')
|
||||
end
|
||||
|
||||
context "jwt sourcedid" do
|
||||
before do
|
||||
dynamic_settings = {
|
||||
"lti-signing-secret" => 'signing-secret-vp04BNqApwdwUYPUI',
|
||||
"lti-encryption-secret" => 'encryption-secret-5T14NjaTbcYjc4'
|
||||
}
|
||||
allow(Canvas::DynamicSettings).to receive(:find) { dynamic_settings }
|
||||
end
|
||||
|
||||
let(:jwt_source_id) do
|
||||
BasicLTI::Sourcedid.new(tool, @course, assignment, @user).to_s
|
||||
end
|
||||
|
||||
it "decodes a jwt signed sourcedid" do
|
||||
expect(described_class.decode_source_id(tool, jwt_source_id)).to eq [@course, assignment, @user]
|
||||
end
|
||||
|
||||
it 'throws invalid JWT if token is unrecognized' do
|
||||
missing_signature = source_id.split('-')[0..3].join('-')
|
||||
expect{described_class.decode_source_id(tool, missing_signature)}.
|
||||
to raise_error(BasicLTI::Errors::InvalidSourceId, 'Invalid sourcedid')
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
describe "#handle_request" do
|
||||
|
|
|
@ -0,0 +1,137 @@
|
|||
#
|
||||
# Copyright (C) 2017 - present Instructure, Inc.
|
||||
#
|
||||
# This file is part of Canvas.
|
||||
#
|
||||
# Canvas is free software: you can redistribute it and/or modify it under
|
||||
# the terms of the GNU Affero General Public License as published by the Free
|
||||
# Software Foundation, version 3 of the License.
|
||||
#
|
||||
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along
|
||||
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
require File.expand_path(File.dirname(__FILE__) + '/../../spec_helper.rb')
|
||||
|
||||
describe BasicLTI::Sourcedid do
|
||||
subject(:sourcedid) {described_class.new(tool, course, assignment, user)}
|
||||
|
||||
let(:tool) { external_tool_model(context: course) }
|
||||
let(:course) { course_model }
|
||||
let(:assignment) do
|
||||
course.assignments.create!(
|
||||
{
|
||||
title: "value for title",
|
||||
description: "value for description",
|
||||
due_at: Time.zone.now,
|
||||
points_possible: "1.5",
|
||||
submission_types: 'external_tool',
|
||||
external_tool_tag_attributes: {url: tool.url}
|
||||
}
|
||||
)
|
||||
end
|
||||
let(:user) { course_with_student(course: course).user }
|
||||
|
||||
before do
|
||||
fake_secrets = {
|
||||
'lti-signing-secret' => 'signing-secret-vp04BNqApwdwUYPUI',
|
||||
'lti-encryption-secret' => 'encryption-secret-5T14NjaTbcYjc4',
|
||||
}
|
||||
|
||||
allow(Canvas::DynamicSettings).to receive(:find).and_return(fake_secrets)
|
||||
end
|
||||
|
||||
it 'creates a signed and encrypted sourcedid' do
|
||||
timestamp = Time.zone.now
|
||||
allow(Time.zone).to receive(:now).and_return(timestamp)
|
||||
|
||||
token = BasicLTI::Sourcedid.token_from_sourcedid!(sourcedid.to_s)
|
||||
|
||||
expect(token[:iss]).to eq 'Canvas'
|
||||
expect(token[:aud]).to eq ['Instructure']
|
||||
expect(token[:iat]).to eq timestamp.to_i
|
||||
expect(token[:tool_id]).to eq tool.id
|
||||
expect(token[:course_id]).to eq course.id
|
||||
expect(token[:assignment_id]).to eq assignment.id
|
||||
expect(token[:user_id]).to eq user.id
|
||||
end
|
||||
|
||||
describe ".load!" do
|
||||
it 'raises an exception for an invalid sourcedid' do
|
||||
expect{ described_class.load!('invalid-sourcedid') }.to raise_error(
|
||||
BasicLTI::Errors::InvalidSourceId, 'Invalid sourcedid'
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises an exception for a nil sourcedid' do
|
||||
expect{ described_class.load!(nil) }.to raise_error(
|
||||
BasicLTI::Errors::InvalidSourceId, 'Invalid sourcedid'
|
||||
)
|
||||
end
|
||||
|
||||
context "legacy sourcedid" do
|
||||
it 'raises an exception when improperly signed' do
|
||||
sourcedid = "#{tool.id}-#{course.id}-#{assignment.id}-#{user.id}-badsignature"
|
||||
expect{ described_class.load!(sourcedid) }.to raise_error(
|
||||
BasicLTI::Errors::InvalidSourceId, 'Invalid signature'
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises an exception when the tool id is invalid' do
|
||||
sourcedid = "9876543210-#{course.id}-#{assignment.id}-#{user.id}-badsignature"
|
||||
expect{ described_class.load!(sourcedid) }.to raise_error(
|
||||
BasicLTI::Errors::InvalidSourceId, 'Tool is invalid'
|
||||
)
|
||||
end
|
||||
|
||||
it 'builds a sourcedid' do
|
||||
payload = [tool.id, course.id, assignment.id, user.id].join('-')
|
||||
legacy_sourcedid = "#{payload}-#{Canvas::Security.hmac_sha1(payload)}"
|
||||
|
||||
sourcedid = described_class.load!(legacy_sourcedid)
|
||||
|
||||
expect(sourcedid.tool).to eq tool
|
||||
expect(sourcedid.course).to eq course
|
||||
expect(sourcedid.assignment).to eq assignment
|
||||
expect(sourcedid.user).to eq user
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises an exception when the course is invalid' do
|
||||
course.destroy!
|
||||
|
||||
expect{ described_class.load!(sourcedid.to_s) }.to raise_error(
|
||||
BasicLTI::Errors::InvalidSourceId, 'Course is invalid'
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises an exception when the user is not in the course' do
|
||||
user.enrollments.find_by(course_id: course.id).destroy!
|
||||
|
||||
expect{ described_class.load!(sourcedid.to_s) }.to raise_error(
|
||||
BasicLTI::Errors::InvalidSourceId, 'User is no longer in course'
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises an exception when the assignment is not valid' do
|
||||
assignment.destroy!
|
||||
|
||||
expect{ described_class.load!(sourcedid.to_s) }.to raise_error(
|
||||
BasicLTI::Errors::InvalidSourceId, 'Assignment is invalid'
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises an exception when the assignment is not associated with the tool' do
|
||||
assignment.external_tool_tag.update(url: 'http://invalidurl.com')
|
||||
|
||||
expect{ described_class.load!(sourcedid.to_s) }.to raise_error(
|
||||
BasicLTI::Errors::InvalidSourceId, 'Assignment is no longer associated with this tool'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -20,6 +20,11 @@ require File.expand_path(File.dirname(__FILE__) + '/turnitin_spec_helper')
|
|||
require 'turnitin_api'
|
||||
module Turnitin
|
||||
describe OutcomeResponseProcessor do
|
||||
before do
|
||||
allow(BasicLTI::Sourcedid).to receive(:encryption_secret) {'encryption-secret-5T14NjaTbcYjc4'}
|
||||
allow(BasicLTI::Sourcedid).to receive(:signing_secret) {'signing-secret-vp04BNqApwdwUYPUI'}
|
||||
end
|
||||
|
||||
include_context "shared_tii_lti"
|
||||
subject { described_class.new(tool, lti_assignment, lti_student, outcome_response_json) }
|
||||
|
||||
|
|
|
@ -18,6 +18,11 @@
|
|||
require File.expand_path(File.dirname(__FILE__) + '/../../spec_helper.rb')
|
||||
|
||||
RSpec.shared_context "shared_tii_lti", :shared_context => :metadata do
|
||||
before do
|
||||
allow(BasicLTI::Sourcedid).to receive(:encryption_secret) {'encryption-secret-5T14NjaTbcYjc4'}
|
||||
allow(BasicLTI::Sourcedid).to receive(:signing_secret) {'signing-secret-vp04BNqApwdwUYPUI'}
|
||||
end
|
||||
|
||||
let(:lti_student) { user_model }
|
||||
let(:lti_course) { course_with_student({user: lti_student}).course }
|
||||
let(:tool) do
|
||||
|
|
|
@ -392,6 +392,11 @@ describe "LTI integration tests" do
|
|||
end
|
||||
|
||||
context "outcome launch" do
|
||||
before do
|
||||
allow(BasicLTI::Sourcedid).to receive(:encryption_secret) {'encryption-secret-5T14NjaTbcYjc4'}
|
||||
allow(BasicLTI::Sourcedid).to receive(:signing_secret) {'signing-secret-vp04BNqApwdwUYPUI'}
|
||||
end
|
||||
|
||||
def tool_setup(for_student=true)
|
||||
if for_student
|
||||
course_with_student(:active_all => true)
|
||||
|
@ -409,11 +414,12 @@ describe "LTI integration tests" do
|
|||
end
|
||||
|
||||
it "should include assignment outcome service params for student" do
|
||||
allow(Canvas::Security).to receive(:hmac_sha1).and_return('some_sha')
|
||||
hash = tool_setup
|
||||
allow(Canvas::Security).to receive(:create_encrypted_jwt) { 'an.encrypted.jwt' }
|
||||
allow_any_instance_of(Account).to receive(:feature_enabled?) { false }
|
||||
allow_any_instance_of(Account).to receive(:feature_enabled?).with(:encrypted_sourcedids).and_return(true)
|
||||
|
||||
payload = [@tool.id, @course.id, @assignment.id, @user.id].join('-')
|
||||
expect(hash['lis_result_sourcedid']).to eq "#{payload}-some_sha"
|
||||
hash = tool_setup
|
||||
expect(hash['lis_result_sourcedid']).to eq BasicLTI::Sourcedid.new(@tool, @course, @assignment, @user).to_s
|
||||
expect(hash['lis_outcome_service_url']).to eq "/my/test/url"
|
||||
expect(hash['ext_ims_lis_basic_outcome_url']).to eq "/my/other/test/url"
|
||||
expect(hash['ext_outcome_data_values_accepted']).to eq 'url,text'
|
||||
|
|
|
@ -238,6 +238,8 @@ describe Lti::LtiOutboundAdapter do
|
|||
|
||||
before(:each) do
|
||||
allow(LtiOutbound::ToolLaunch).to receive(:new).and_return(tool_launch)
|
||||
allow(BasicLTI::Sourcedid).to receive(:encryption_secret) {'encryption-secret-5T14NjaTbcYjc4'}
|
||||
allow(BasicLTI::Sourcedid).to receive(:signing_secret) {'signing-secret-vp04BNqApwdwUYPUI'}
|
||||
end
|
||||
|
||||
it "includes the 'ext_lti_assignment_id' parameter" do
|
||||
|
@ -256,20 +258,6 @@ describe Lti::LtiOutboundAdapter do
|
|||
adapter.generate_post_payload_for_assignment(assignment, outcome_service_url, legacy_outcome_service_url, lti_turnitin_outcomes_placement_url)
|
||||
end
|
||||
|
||||
it "generates the correct source_id for the assignment" do
|
||||
generated_sha = 'generated_sha'
|
||||
allow(Canvas::Security).to receive(:hmac_sha1).and_return(generated_sha)
|
||||
source_id = "tool_id-course_id-assignment_id-#{user.id}-#{generated_sha}"
|
||||
allow(tool_launch).to receive(:for_assignment!)
|
||||
assignment_creator = double
|
||||
allow(assignment_creator).to receive(:convert).and_return(tool_launch)
|
||||
adapter.prepare_tool_launch(return_url, variable_expander)
|
||||
|
||||
expect(Lti::LtiAssignmentCreator).to receive(:new).with(assignment, source_id).and_return(assignment_creator)
|
||||
|
||||
adapter.generate_post_payload_for_assignment(assignment, outcome_service_url, legacy_outcome_service_url, lti_turnitin_outcomes_placement_url)
|
||||
end
|
||||
|
||||
it "raises a not prepared error if the tool launch has not been prepared" do
|
||||
expect {
|
||||
adapter.generate_post_payload_for_assignment(assignment, outcome_service_url, legacy_outcome_service_url, lti_turnitin_outcomes_placement_url)
|
||||
|
@ -315,4 +303,62 @@ describe Lti::LtiOutboundAdapter do
|
|||
expect(Lti::LtiOutboundAdapter.consumer_instance_class).to eq LtiOutbound::LTIConsumerInstance
|
||||
end
|
||||
end
|
||||
|
||||
describe '#encode_source_id' do
|
||||
let(:user) do
|
||||
student_in_course
|
||||
@student
|
||||
end
|
||||
let(:assignment) { assignment_model(course: @course) }
|
||||
let(:course) { assignment.course }
|
||||
let(:tool) { external_tool_model(context: course) }
|
||||
let(:adapter) { Lti::LtiOutboundAdapter.new(tool, user, course) }
|
||||
let(:enrollment) { StudentEnrollment.create!(user: user, course: course, workflow_state: 'active') }
|
||||
|
||||
before do
|
||||
allow(BasicLTI::Sourcedid).to receive(:encryption_secret) {'encryption-secret-5T14NjaTbcYjc4'}
|
||||
allow(BasicLTI::Sourcedid).to receive(:signing_secret) {'signing-secret-vp04BNqApwdwUYPUI'}
|
||||
assignment.update_attributes!(
|
||||
external_tool_tag: ContentTag.create!(
|
||||
context: assignment,
|
||||
content: tool,
|
||||
title: 'test',
|
||||
url: tool.url
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
it 'builds the expected encrypted JWT with the correct course data' do
|
||||
allow_any_instance_of(Account).to receive(:feature_enabled?).with(:encrypted_sourcedids).and_return(true)
|
||||
sourced_id = adapter.encode_source_id(assignment)
|
||||
parsed_sourced_id = BasicLTI::Sourcedid.load! sourced_id
|
||||
expect(parsed_sourced_id.course).to eq course
|
||||
end
|
||||
|
||||
it 'builds the expected encrypted JWT with the correct assignment data' do
|
||||
allow_any_instance_of(Account).to receive(:feature_enabled?).with(:encrypted_sourcedids).and_return(true)
|
||||
sourced_id = adapter.encode_source_id(assignment)
|
||||
parsed_sourced_id = BasicLTI::Sourcedid.load! sourced_id
|
||||
expect(parsed_sourced_id.assignment).to eq assignment
|
||||
end
|
||||
|
||||
it 'builds the expected encrypted JWT with the correct user data' do
|
||||
allow_any_instance_of(Account).to receive(:feature_enabled?).with(:encrypted_sourcedids).and_return(true)
|
||||
sourced_id = adapter.encode_source_id(assignment)
|
||||
parsed_sourced_id = BasicLTI::Sourcedid.load! sourced_id
|
||||
expect(parsed_sourced_id.user).to eq user
|
||||
end
|
||||
|
||||
it 'uses the new sourcedids if the "encrypted_sourcedids" FF is enabled' do
|
||||
allow_any_instance_of(Account).to receive(:feature_enabled?).with(:encrypted_sourcedids).and_return(true)
|
||||
sourced_id = adapter.encode_source_id(assignment)
|
||||
expect(sourced_id).not_to match(BasicLTI::Sourcedid::SOURCE_ID_REGEX)
|
||||
end
|
||||
|
||||
it 'uses legacy sourcedids if the "encrypted_sourcedids" FF is disabled' do
|
||||
allow_any_instance_of(Account).to receive(:feature_enabled?).with(:encrypted_sourcedids).and_return(false)
|
||||
sourced_id = adapter.encode_source_id(assignment)
|
||||
expect(sourced_id).to match(BasicLTI::Sourcedid::SOURCE_ID_REGEX)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -105,6 +105,8 @@ describe "external tool assignments" do
|
|||
end
|
||||
|
||||
it "should show module sequence even without module_item_id param" do
|
||||
allow(BasicLTI::Sourcedid).to receive(:encryption_secret) {'encryption-secret-5T14NjaTbcYjc4'}
|
||||
allow(BasicLTI::Sourcedid).to receive(:signing_secret) {'signing-secret-vp04BNqApwdwUYPUI'}
|
||||
a = assignment_model(:course => @course, :title => "test2", :submission_types => 'external_tool')
|
||||
a.create_external_tool_tag(:url => @t1.url)
|
||||
a.external_tool_tag.update_attribute(:content_type, 'ContextExternalTool')
|
||||
|
|
|
@ -492,6 +492,8 @@ describe "context modules" do
|
|||
end
|
||||
|
||||
it "shows Mark as Done button for assignments with external tool submission", priority: "2", test_id: 3340306 do
|
||||
allow(BasicLTI::Sourcedid).to receive(:encryption_secret) {'encryption-secret-5T14NjaTbcYjc4'}
|
||||
allow(BasicLTI::Sourcedid).to receive(:signing_secret) {'signing-secret-vp04BNqApwdwUYPUI'}
|
||||
tool = @course.context_external_tools.create!(name: "a",
|
||||
url: "example.com",
|
||||
consumer_key: '12345',
|
||||
|
|
|
@ -87,6 +87,8 @@ describe "assignments" do
|
|||
end
|
||||
|
||||
it "should validate an assignment created with the type of external tool", priority: "1", test_id: 2624905 do
|
||||
allow(BasicLTI::Sourcedid).to receive(:encryption_secret) {'encryption-secret-5T14NjaTbcYjc4'}
|
||||
allow(BasicLTI::Sourcedid).to receive(:signing_secret) {'signing-secret-vp04BNqApwdwUYPUI'}
|
||||
t1 = factory_with_protected_attributes(@course.context_external_tools, :url => "http://www.example.com/", :shared_secret => 'test123', :consumer_key => 'test123', :name => 'tool 1')
|
||||
external_tool_assignment = assignment_model(:course => @course, :title => "test2", :submission_types => 'external_tool')
|
||||
external_tool_assignment.create_external_tool_tag(:url => t1.url)
|
||||
|
|
Loading…
Reference in New Issue