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:
Weston Dransfield 2017-11-20 17:07:38 +00:00
parent 90f2530420
commit 278682f152
21 changed files with 466 additions and 70 deletions

View File

@ -113,10 +113,14 @@ module Lti
# 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 encode_source_id(assignment) def encode_source_id(assignment)
@tool.shard.activate do @tool.shard.activate do
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 = [@tool.id, @context.id, assignment.id, @user.id].join('-')
"#{payload}-#{Canvas::Security.hmac_sha1(payload)}" "#{payload}-#{Canvas::Security.hmac_sha1(payload)}"
end end
end end
end
private private
def default_launch_url(resource_type = nil) def default_launch_url(resource_type = nil)

View File

@ -33,35 +33,13 @@ module BasicLTI
end end
end end
class InvalidSourceId < StandardError
end
SOURCE_ID_REGEX = %r{^(\d+)-(\d+)-(\d+)-(\d+)-(\w+)$} SOURCE_ID_REGEX = %r{^(\d+)-(\d+)-(\d+)-(\d+)-(\w+)$}
def self.decode_source_id(tool, sourceid) def self.decode_source_id(tool, sourceid)
tool.shard.activate do tool.shard.activate do
raise InvalidSourceId, 'Invalid sourcedid' if sourceid.blank? sourcedid = BasicLTI::Sourcedid.load!(sourceid)
md = sourceid.match(SOURCE_ID_REGEX) raise BasicLTI::Errors::InvalidSourceId, 'Tool is invalid' unless tool == sourcedid.tool
raise InvalidSourceId, 'Invalid sourcedid' unless md return sourcedid.course, sourcedid.assignment, sourcedid.user
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
end end
end end
@ -183,7 +161,7 @@ module BasicLTI
begin begin
course, assignment, user = BasicLTI::BasicOutcomes.decode_source_id(tool, source_id) course, assignment, user = BasicLTI::BasicOutcomes.decode_source_id(tool, source_id)
rescue InvalidSourceId => e rescue Errors::InvalidSourceId => e
self.code_major = 'failure' self.code_major = 'failure'
self.description = e.to_s self.description = e.to_s
self.body = "<#{operation_ref_identifier}Response />" self.body = "<#{operation_ref_identifier}Response />"

24
lib/basic_lti/errors.rb Normal file
View File

@ -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

110
lib/basic_lti/sourcedid.rb Normal file
View File

@ -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

View File

@ -557,6 +557,12 @@ END
state: "allowed", state: "allowed",
root_opt_in: true 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 def self.definitions

View File

@ -39,6 +39,11 @@ describe LtiApiController, type: :request do
} }
end 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) 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") consumer = OAuth::Consumer.new(tool.consumer_key, tool.shared_secret, :site => "https://www.example.com", :signature_method => "HMAC-SHA1")

View File

@ -118,6 +118,11 @@ describe ExternalToolsController, type: :request do
end end
context 'assessment launch' do 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 it 'returns a bad request response if there is no assignment_id' do
params = {id: tool.id.to_s, launch_type: 'assessment'} params = {id: tool.id.to_s, launch_type: 'assessment'}
code = get_raw_sessionless_launch_url(@course, 'course', params) code = get_raw_sessionless_launch_url(@course, 'course', params)

View File

@ -439,6 +439,8 @@ describe AssignmentsController do
Setting.set('enable_page_views', 'db') Setting.set('enable_page_views', 'db')
@old_thread_context = Thread.current[:context] @old_thread_context = Thread.current[:context]
Thread.current[:context] = { request_id: SecureRandom.uuid } 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 end
after do after do

View File

@ -1227,6 +1227,11 @@ describe ExternalToolsController do
end end
describe "'GET 'generate_sessionless_launch'" do 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 it "generates a sessionless launch" do
@tool = new_valid_tool(@course) @tool = new_valid_tool(@course)
user_session(@user) user_session(@user)

View File

@ -32,6 +32,11 @@ describe LtiApiController, type: :request do
tag.save! tag.save!
end 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) def check_error_response(message, check_generated_sig=true)
expect(response.body.strip).to_not be_empty, "Should not have an empty response body" 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>[^\]]+)\]/) 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[:description]).to eq error_message if error_message
expect(desc[:error_report]).to_not be_empty expect(desc[:error_report]).to_not be_empty
end end
def check_success def check_success

View File

@ -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

View File

@ -23,6 +23,8 @@ require 'nokogiri'
describe "External Tools" do describe "External Tools" do
describe "Assignments" do describe "Assignments" do
before 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) course_factory(active_all: true)
assignment_model(:course => @course, :submission_types => "external_tool", :points_possible => 25) 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') @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 end
it "should include outcome service params when viewing as student" do 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) student_in_course(:course => @course, :active_all => true)
user_session(@user) 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}" get "/courses/#{@course.id}/assignments/#{@assignment.id}"
expect(response).to be_success expect(response).to be_success
doc = Nokogiri::HTML.parse(response.body) 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#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) 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 end

View File

@ -109,63 +109,56 @@ describe BasicLTI::BasicOutcomes do
end end
describe ".decode_source_id" do describe ".decode_source_id" do
it 'successfully decodes a source_id' do it 'successfully decodes a source_id' do
expect(described_class.decode_source_id(tool, source_id)).to eq [@course, assignment, @user] expect(described_class.decode_source_id(tool, source_id)).to eq [@course, assignment, @user]
end end
it 'throws Invalid sourcedid if sourcedid is nil' do it 'throws Invalid sourcedid if sourcedid is nil' do
expect{described_class.decode_source_id(tool, nil)}. 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 end
it 'throws Invalid sourcedid if sourcedid is empty' do it 'throws Invalid sourcedid if sourcedid is empty' do
expect{described_class.decode_source_id(tool, "")}. expect{described_class.decode_source_id(tool, "")}.
to raise_error(BasicLTI::BasicOutcomes::InvalidSourceId, 'Invalid sourcedid') to raise_error(BasicLTI::Errors::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')
end end
it 'throws Invalid signature if the signature is invalid' do it 'throws Invalid signature if the signature is invalid' do
bad_signature = source_id.split('-')[0..3].join('-') + '-asb9dksld9k3' bad_signature = source_id.split('-')[0..3].join('-') + '-asb9dksld9k3'
expect{described_class.decode_source_id(tool, bad_signature)}. 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 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. 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))}. 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 end
it "throws Course is invalid if the course doesn't match" do it "throws Course is invalid if the course doesn't match" do
@course.workflow_state = 'deleted' @course.workflow_state = 'deleted'
@course.save! @course.save!
expect{described_class.decode_source_id(tool, source_id)}. 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 end
it "throws User is no longer in course isuser enrollment is missing" do it "throws User is no longer in course isuser enrollment is missing" do
@user.enrollments.destroy_all @user.enrollments.destroy_all
expect{described_class.decode_source_id(tool, source_id)}. 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 end
it "throws Assignment is invalid if the Addignment doesn't match" do it "throws Assignment is invalid if the Addignment doesn't match" do
assignment.destroy assignment.destroy
expect{described_class.decode_source_id(tool, source_id)}. 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 end
it "throws Assignment is no longer associated with this tool if tool is deleted" do it "throws Assignment is no longer associated with this tool if tool is deleted" do
tool.destroy tool.destroy
expect{described_class.decode_source_id(tool, source_id)}. 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 end
it "throws Assignment is no longer associated with this tool if tool doesn't match the url" do 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.url = 'example.com'
tag.save! tag.save!
expect{described_class.decode_source_id(tool, source_id)}. 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 end
it "throws Assignment is no longer associated with this tool if tag is missing" do it "throws Assignment is no longer associated with this tool if tag is missing" do
assignment.external_tool_tag.delete assignment.external_tool_tag.delete
expect{described_class.decode_source_id(tool, source_id)}. 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 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 end
describe "#handle_request" do describe "#handle_request" do

View File

@ -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

View File

@ -20,6 +20,11 @@ require File.expand_path(File.dirname(__FILE__) + '/turnitin_spec_helper')
require 'turnitin_api' require 'turnitin_api'
module Turnitin module Turnitin
describe OutcomeResponseProcessor do 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" include_context "shared_tii_lti"
subject { described_class.new(tool, lti_assignment, lti_student, outcome_response_json) } subject { described_class.new(tool, lti_assignment, lti_student, outcome_response_json) }

View File

@ -18,6 +18,11 @@
require File.expand_path(File.dirname(__FILE__) + '/../../spec_helper.rb') require File.expand_path(File.dirname(__FILE__) + '/../../spec_helper.rb')
RSpec.shared_context "shared_tii_lti", :shared_context => :metadata do 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_student) { user_model }
let(:lti_course) { course_with_student({user: lti_student}).course } let(:lti_course) { course_with_student({user: lti_student}).course }
let(:tool) do let(:tool) do

View File

@ -392,6 +392,11 @@ describe "LTI integration tests" do
end end
context "outcome launch" do 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) def tool_setup(for_student=true)
if for_student if for_student
course_with_student(:active_all => true) course_with_student(:active_all => true)
@ -409,11 +414,12 @@ describe "LTI integration tests" do
end end
it "should include assignment outcome service params for student" do it "should include assignment outcome service params for student" do
allow(Canvas::Security).to receive(:hmac_sha1).and_return('some_sha') allow(Canvas::Security).to receive(:create_encrypted_jwt) { 'an.encrypted.jwt' }
hash = tool_setup 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('-') hash = tool_setup
expect(hash['lis_result_sourcedid']).to eq "#{payload}-some_sha" 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['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_ims_lis_basic_outcome_url']).to eq "/my/other/test/url"
expect(hash['ext_outcome_data_values_accepted']).to eq 'url,text' expect(hash['ext_outcome_data_values_accepted']).to eq 'url,text'

View File

@ -238,6 +238,8 @@ describe Lti::LtiOutboundAdapter do
before(:each) do before(:each) do
allow(LtiOutbound::ToolLaunch).to receive(:new).and_return(tool_launch) 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 end
it "includes the 'ext_lti_assignment_id' parameter" do 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) adapter.generate_post_payload_for_assignment(assignment, outcome_service_url, legacy_outcome_service_url, lti_turnitin_outcomes_placement_url)
end 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 it "raises a not prepared error if the tool launch has not been prepared" do
expect { expect {
adapter.generate_post_payload_for_assignment(assignment, outcome_service_url, legacy_outcome_service_url, lti_turnitin_outcomes_placement_url) 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 expect(Lti::LtiOutboundAdapter.consumer_instance_class).to eq LtiOutbound::LTIConsumerInstance
end end
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 end

View File

@ -105,6 +105,8 @@ describe "external tool assignments" do
end end
it "should show module sequence even without module_item_id param" do 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 = assignment_model(:course => @course, :title => "test2", :submission_types => 'external_tool')
a.create_external_tool_tag(:url => @t1.url) a.create_external_tool_tag(:url => @t1.url)
a.external_tool_tag.update_attribute(:content_type, 'ContextExternalTool') a.external_tool_tag.update_attribute(:content_type, 'ContextExternalTool')

View File

@ -492,6 +492,8 @@ describe "context modules" do
end end
it "shows Mark as Done button for assignments with external tool submission", priority: "2", test_id: 3340306 do 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", tool = @course.context_external_tools.create!(name: "a",
url: "example.com", url: "example.com",
consumer_key: '12345', consumer_key: '12345',

View File

@ -87,6 +87,8 @@ describe "assignments" do
end end
it "should validate an assignment created with the type of external tool", priority: "1", test_id: 2624905 do 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') 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 = assignment_model(:course => @course, :title => "test2", :submission_types => 'external_tool')
external_tool_assignment.create_external_tool_tag(:url => t1.url) external_tool_assignment.create_external_tool_tag(:url => t1.url)