Store LTI message in Redis

Closes PLAT-3949

Test Plan:
- Verify the following for assignment, course nav,
  and user nav placements:
  * The ID token is sored as JSON in redis using
    the same key scheme as sessionless launches
  * The ID token contains all resource link
    request claims
- The generate sesionles launch endpoint works
  as before.
- The sessionless launch endpoint works as before.

Change-Id: I5a3b201ab460facedd4eb03094fbac0ca12a4546
Reviewed-on: https://gerrit.instructure.com/170827
Tested-by: Jenkins
Reviewed-by: Marc Phillips <mphillips@instructure.com>
QA-Review: Marc Phillips <mphillips@instructure.com>
Product-Review: Weston Dransfield <wdransfield@instructure.com>
This commit is contained in:
wdransfield 2018-11-02 08:47:44 -06:00 committed by Weston Dransfield
parent 97a910bcf0
commit 160356823e
10 changed files with 172 additions and 20 deletions

View File

@ -1618,6 +1618,7 @@ class ApplicationController < ActionController::Base
@lti_launch.params = adapter.generate_post_payload
end
adapter.cache_payload if adapter.respond_to?(:cache_payload)
@lti_launch.resource_url = @resource_url
@lti_launch.link_text = @resource_title
@lti_launch.analytics_id = @tool.tool_id

View File

@ -30,8 +30,8 @@ class ExternalToolsController < ApplicationController
before_action :get_context, :only => [:retrieve, :show, :resource_selection]
skip_before_action :verify_authenticity_token, only: :resource_selection
include Api::V1::ExternalTools
include Lti::RedisMessageClient
REDIS_PREFIX = 'external_tool:sessionless_launch:'.freeze
WHITELISTED_QUERY_PARAMS = [
:platform
].freeze
@ -242,9 +242,11 @@ class ExternalToolsController < ApplicationController
def sessionless_launch
if Canvas.redis_enabled?
redis_key = "#{@context.class.name}:#{REDIS_PREFIX}#{params[:verifier]}"
launch_settings = Canvas.redis.get(redis_key)
Canvas.redis.del(redis_key)
launch_settings = fetch_and_delete_launch(
@context,
params[:verifier],
prefix: Lti::RedisMessageClient::SESSIONLESS_LAUNCH_PREFIX
)
end
unless launch_settings
render :plain => t(:cannot_locate_launch_request, 'Cannot locate launch request, please try again.'), :status => :not_found
@ -503,11 +505,11 @@ class ExternalToolsController < ApplicationController
opts: opts
)
else
Lti::LtiOutboundAdapter.new(tool, @current_user, @context).prepare_tool_launch(
@return_url,
expander,
opts
)
Lti::LtiOutboundAdapter.new(tool, @current_user, @context).prepare_tool_launch(
@return_url,
expander,
opts
)
end
lti_launch.params = if selection_type == 'homework_submission' && assignment
@ -516,6 +518,7 @@ class ExternalToolsController < ApplicationController
adapter.generate_post_payload
end
adapter.cache_payload if adapter.respond_to?(:cache_payload)
lti_launch.resource_url = opts[:launch_url] || adapter.launch_url
lti_launch.link_text = selection_type ? tool.label_for(selection_type.to_sym, I18n.locale) : tool.default_label
lti_launch.analytics_id = tool.tool_id
@ -1157,8 +1160,7 @@ class ExternalToolsController < ApplicationController
end
# store the launch settings and return to the user
verifier = SecureRandom.hex(64)
Canvas.redis.setex("#{@context.class.name}:#{REDIS_PREFIX}#{verifier}", 5.minutes, launch_settings.to_json)
verifier = cache_launch(launch_settings, @context, prefix: Lti::RedisMessageClient::SESSIONLESS_LAUNCH_PREFIX)
uri = if @context.is_a?(Account)
URI(account_external_tools_sessionless_launch_url(@context))

View File

@ -1284,6 +1284,8 @@ class UsersController < ApplicationController
else
Lti::LtiOutboundAdapter.new(@tool, @current_user, @domain_root_account).prepare_tool_launch(@return_url, variable_expander, opts)
end
adapter.cache_payload if adapter.respond_to?(:cache_payload)
@lti_launch.params = adapter.generate_post_payload
@lti_launch.resource_url = @tool.user_navigation(:url)

View File

@ -17,6 +17,8 @@
module Lti
class LtiAdvantageAdapter
include Lti::RedisMessageClient
delegate :generate_post_payload_for_assignment, to: :resource_link_request
delegate :generate_post_payload_for_homework_submission, to: :resource_link_request
@ -43,6 +45,10 @@ module Lti
resource_type ? @tool.extension_setting(resource_type, :url) : @tool.url
end
def cache_payload
cache_launch(generate_post_payload, @context)
end
private
def resource_link_request

View File

@ -0,0 +1,37 @@
#
# Copyright (C) 2018 - 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 Lti::RedisMessageClient
TTL = 5.minutes
LTI_1_3_PREFIX = 'external_tool:id_token:'.freeze
SESSIONLESS_LAUNCH_PREFIX = 'external_tool:sessionless_launch:'.freeze
def cache_launch(launch, context, prefix: LTI_1_3_PREFIX)
return unless Canvas.redis_enabled?
verifier = SecureRandom.hex(64)
Canvas.redis.setex("#{context.class.name}:#{prefix}#{verifier}", 5.minutes, launch.to_json)
verifier
end
def fetch_and_delete_launch(context, verifier, prefix: LTI_1_3_PREFIX)
return unless Canvas.redis_enabled?
redis_key = "#{context.class.name}:#{prefix}#{verifier}"
launch = Canvas.redis.get(redis_key)
Canvas.redis.del(redis_key)
launch
end
end

View File

@ -606,21 +606,39 @@ RSpec.describe ApplicationController do
tool.save!
end
shared_examples_for 'a placement that caches the launch' do
let(:verifier) { "e5e774d015f42370dcca2893025467b414d39009dfe9a55250279cca16f5f3c2704f9c56fef4cea32825a8f72282fa139298cf846e0110238900567923f9d057" }
let(:redis_key) { "#{course.class.name}:#{Lti::RedisMessageClient::LTI_1_3_PREFIX}#{verifier}" }
let(:cached_launch) { JSON::JWT.decode(JSON.parse(Canvas.redis.get(redis_key))['id_token'], :skip_verification) }
before { allow(SecureRandom).to receive(:hex).and_return(verifier) }
it 'caches the LTI 1.3 launch' do
controller.send(:content_tag_redirect, course, content_tag, nil)
expect(cached_launch["https://purl.imsglobal.org/spec/lti/claim/message_type"]).to eq "LtiResourceLinkRequest"
end
end
context 'assignments' do
it 'creates a resource link request when tool is configured to use LTI 1.3' do
controller.send(:content_tag_redirect, course, content_tag, nil)
jwt = JSON::JWT.decode(assigns[:lti_launch].params[:id_token], :skip_verification)
expect(jwt["https://purl.imsglobal.org/spec/lti/claim/message_type"]).to eq "LtiResourceLinkRequest"
end
it_behaves_like 'a placement that caches the launch'
end
context 'module items' do
before { content_tag.update!(context: course.account) }
it 'creates a resource link request when tool is configured to use LTI 1.3' do
content_tag.update!(context: course.account)
controller.send(:content_tag_redirect, course, content_tag, nil)
jwt = JSON::JWT.decode(assigns[:lti_launch].params[:id_token], :skip_verification)
expect(jwt["https://purl.imsglobal.org/spec/lti/claim/message_type"]).to eq "LtiResourceLinkRequest"
end
it_behaves_like 'a placement that caches the launch'
end
end

View File

@ -113,12 +113,24 @@ describe ExternalToolsController do
tool
end
it 'creates a resource link request when tool is configured to use LTI 1.3' do
let(:verifier) { "e5e774d015f42370dcca2893025467b414d39009dfe9a55250279cca16f5f3c2704f9c56fef4cea32825a8f72282fa139298cf846e0110238900567923f9d057" }
let(:redis_key) { "#{@course.class.name}:#{Lti::RedisMessageClient::LTI_1_3_PREFIX}#{verifier}" }
let(:cached_launch) { JSON::JWT.decode(JSON.parse(Canvas.redis.get(redis_key))['id_token'], :skip_verification) }
before do
allow(SecureRandom).to receive(:hex).and_return(verifier)
user_session(@teacher)
get :show, params: {:course_id => @course.id, id: tool.id}
end
it 'creates a resource link request when tool is configured to use LTI 1.3' do
jwt = JSON::JWT.decode(assigns[:lti_launch].params[:id_token], :skip_verification)
expect(jwt["https://purl.imsglobal.org/spec/lti/claim/message_type"]).to eq "LtiResourceLinkRequest"
end
it 'caches the the LTI 1.3 launch' do
expect(cached_launch["https://purl.imsglobal.org/spec/lti/claim/message_type"]).to eq "LtiResourceLinkRequest"
end
end
context 'basic-lti-launch-request' do
@ -1435,7 +1447,7 @@ describe ExternalToolsController do
json = JSON.parse(response.body.sub(/^while\(1\)\;/, ''))
verifier = CGI.parse(URI.parse(json['url']).query)['verifier'].first
redis_key = "#{@course.class.name}:#{ExternalToolsController::REDIS_PREFIX}#{verifier}"
redis_key = "#{@course.class.name}:#{Lti::RedisMessageClient::SESSIONLESS_LAUNCH_PREFIX}#{verifier}"
launch_settings = JSON.parse(Canvas.redis.get(redis_key))
tool_settings = launch_settings['tool_settings']
@ -1455,7 +1467,7 @@ describe ExternalToolsController do
json = JSON.parse(response.body.sub(/^while\(1\)\;/, ''))
verifier = CGI.parse(URI.parse(json['url']).query)['verifier'].first
redis_key = "#{@course.class.name}:#{ExternalToolsController::REDIS_PREFIX}#{verifier}"
redis_key = "#{@course.class.name}:#{Lti::RedisMessageClient::SESSIONLESS_LAUNCH_PREFIX}#{verifier}"
launch_settings = JSON.parse(Canvas.redis.get(redis_key))
expect(launch_settings['launch_url']).to eq 'http://www.example.com/basic_lti'
@ -1478,7 +1490,7 @@ describe ExternalToolsController do
json = JSON.parse(response.body.sub(/^while\(1\)\;/, ''))
verifier = CGI.parse(URI.parse(json['url']).query)['verifier'].first
redis_key = "#{@course.class.name}:#{ExternalToolsController::REDIS_PREFIX}#{verifier}"
redis_key = "#{@course.class.name}:#{Lti::RedisMessageClient::SESSIONLESS_LAUNCH_PREFIX}#{verifier}"
launch_settings = JSON.parse(Canvas.redis.get(redis_key))
tool_settings = launch_settings['tool_settings']
@ -1511,7 +1523,7 @@ describe ExternalToolsController do
json = JSON.parse(response.body.sub(/^while\(1\)\;/, ''))
verifier = CGI.parse(URI.parse(json['url']).query)['verifier'].first
redis_key = "#{@course.class.name}:#{ExternalToolsController::REDIS_PREFIX}#{verifier}"
redis_key = "#{@course.class.name}:#{Lti::RedisMessageClient::SESSIONLESS_LAUNCH_PREFIX}#{verifier}"
launch_settings = JSON.parse(Canvas.redis.get(redis_key))
tool_settings = launch_settings['tool_settings']
@ -1557,7 +1569,7 @@ describe ExternalToolsController do
json = JSON.parse(response.body.sub(/^while\(1\)\;/, ''))
verifier = CGI.parse(URI.parse(json['url']).query)['verifier'].first
redis_key = "#{@course.class.name}:#{ExternalToolsController::REDIS_PREFIX}#{verifier}"
redis_key = "#{@course.class.name}:#{Lti::RedisMessageClient::SESSIONLESS_LAUNCH_PREFIX}#{verifier}"
launch_settings = JSON.parse(Canvas.redis.get(redis_key))
expect(launch_settings['tool_settings']['resource_link_id']). to eq opaque_id(@tg)
@ -1585,7 +1597,7 @@ describe ExternalToolsController do
json = JSON.parse(response.body.sub(/^while\(1\)\;/, ''))
verifier = CGI.parse(URI.parse(json['url']).query)['verifier'].first
redis_key = "#{@course.class.name}:#{ExternalToolsController::REDIS_PREFIX}#{verifier}"
redis_key = "#{@course.class.name}:#{Lti::RedisMessageClient::SESSIONLESS_LAUNCH_PREFIX}#{verifier}"
launch_settings = JSON.parse(Canvas.redis.get(redis_key))
expect(launch_settings.dig('tool_settings', 'custom_standard')).to eq @tg.id.to_s
end

View File

@ -82,20 +82,29 @@ describe UsersController do
context 'using LTI 1.3 when specified' do
include_context 'lti_1_3_spec_helper'
let(:verifier) { "e5e774d015f42370dcca2893025467b414d39009dfe9a55250279cca16f5f3c2704f9c56fef4cea32825a8f72282fa139298cf846e0110238900567923f9d057" }
let(:redis_key) { "#{assigns[:domain_root_account].class_name}:#{Lti::RedisMessageClient::LTI_1_3_PREFIX}#{verifier}" }
let(:cached_launch) { JSON::JWT.decode(JSON.parse(Canvas.redis.get(redis_key))['id_token'], :skip_verification) }
subject do
get :external_tool, params: {id:tool.id, user_id:user.id}
JSON::JWT.decode(assigns[:lti_launch].params[:id_token], :skip_verification)
end
before do
allow(SecureRandom).to receive(:hex).and_return(verifier)
tool.use_1_3 = true
tool.developer_key = DeveloperKey.create!
tool.save!
get :external_tool, params: {id:tool.id, user_id:user.id}
end
it 'does LTI 1.3 launch' do
expect(subject["https://purl.imsglobal.org/spec/lti/claim/message_type"]).to eq "LtiResourceLinkRequest"
end
it 'caches the LTI 1.3 launch' do
expect(cached_launch["https://purl.imsglobal.org/spec/lti/claim/message_type"]).to eq "LtiResourceLinkRequest"
end
end
end

View File

@ -0,0 +1,56 @@
#
# Copyright (C) 2018 - 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')
describe Lti::RedisMessageClient do
include Lti::RedisMessageClient
let_once(:context) { course_model }
let(:launch) { {foo: 'bar'} }
describe '#cache_launch' do
it 'caches the launch as JSON' do
verifier = cache_launch(launch, context)
redis_key = "#{context.class.name}:#{Lti::RedisMessageClient::LTI_1_3_PREFIX}#{verifier}"
expect(Canvas.redis.get(redis_key)).to eq launch.to_json
end
it 'allows setting the prefix' do
verifier = cache_launch(launch, context, prefix: Lti::RedisMessageClient::SESSIONLESS_LAUNCH_PREFIX)
redis_key = "#{context.class.name}:#{Lti::RedisMessageClient::SESSIONLESS_LAUNCH_PREFIX}#{verifier}"
expect(Canvas.redis.get(redis_key)).to eq launch.to_json
end
end
describe '#fetch_and_delete_launch' do
let(:redis_key) { cache_launch(launch, context) }
it 'fetches the launch data' do
cached_launch = fetch_and_delete_launch(context, redis_key)
expect(cached_launch).to eq launch.to_json
end
it 'deletes the redis entry' do
fetch_and_delete_launch(context, redis_key)
cached_launch = fetch_and_delete_launch(context, redis_key)
expect(cached_launch).to be_nil
end
end
end

View File

@ -72,6 +72,15 @@ describe Lti::LtiAdvantageAdapter do
end
end
describe "#cache_payload" do
it "caches the payload" do
verifier = adapter.cache_payload
redis_key = "#{course.class.name}:#{Lti::RedisMessageClient::LTI_1_3_PREFIX}#{verifier}"
jwt = JSON::JWT.decode(JSON.parse(Canvas.redis.get(redis_key))['id_token'], :skip_verification)
expect(jwt["https://purl.imsglobal.org/spec/lti/claim/message_type"]).to eq "LtiResourceLinkRequest"
end
end
describe '#generate_post_payload_for_assignment' do
let(:outcome_service_url) { 'https://www.outcome_service_url.com' }
let(:legacy_outcome_service_url) { 'https://www.legacy_url.com' }