panda_pal 5.0.0.beta.4 → 5.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +208 -90
  3. data/app/controllers/panda_pal/lti_controller.rb +0 -18
  4. data/app/controllers/panda_pal/lti_v1_p0_controller.rb +34 -0
  5. data/app/controllers/panda_pal/lti_v1_p3_controller.rb +98 -0
  6. data/app/lib/lti_xml/base_platform.rb +4 -4
  7. data/app/lib/panda_pal/launch_url_helpers.rb +69 -0
  8. data/app/lib/panda_pal/lti_jwt_validator.rb +88 -0
  9. data/app/lib/panda_pal/misc_helper.rb +13 -0
  10. data/app/models/panda_pal/organization.rb +21 -47
  11. data/app/models/panda_pal/organization_concerns/settings_validation.rb +127 -0
  12. data/app/models/panda_pal/organization_concerns/task_scheduling.rb +204 -0
  13. data/app/models/panda_pal/platform.rb +40 -0
  14. data/app/views/panda_pal/lti_v1_p3/login.html.erb +1 -0
  15. data/app/views/panda_pal/partials/_auto_submit_form.html.erb +9 -0
  16. data/config/dev_lti_key.key +27 -0
  17. data/config/routes.rb +12 -2
  18. data/db/migrate/20160412205931_create_panda_pal_organizations.rb +1 -1
  19. data/db/migrate/20160413135653_create_panda_pal_sessions.rb +1 -1
  20. data/db/migrate/20160425130344_add_panda_pal_organization_to_session.rb +1 -1
  21. data/db/migrate/20170106165533_add_salesforce_id_to_organizations.rb +1 -1
  22. data/db/migrate/20171205183457_encrypt_organization_settings.rb +1 -1
  23. data/db/migrate/20171205194657_remove_old_organization_settings.rb +8 -3
  24. data/lib/panda_pal.rb +28 -15
  25. data/lib/panda_pal/engine.rb +8 -39
  26. data/lib/panda_pal/helpers.rb +1 -0
  27. data/lib/panda_pal/helpers/controller_helper.rb +139 -44
  28. data/lib/panda_pal/helpers/route_helper.rb +8 -8
  29. data/lib/panda_pal/helpers/secure_headers.rb +79 -0
  30. data/lib/panda_pal/version.rb +1 -1
  31. data/panda_pal.gemspec +6 -2
  32. data/spec/dummy/config/application.rb +7 -1
  33. data/spec/dummy/config/environments/development.rb +0 -14
  34. data/spec/dummy/config/environments/production.rb +0 -11
  35. data/spec/models/panda_pal/organization/settings_validation_spec.rb +175 -0
  36. data/spec/models/panda_pal/organization/task_scheduling_spec.rb +144 -0
  37. data/spec/models/panda_pal/organization_spec.rb +0 -89
  38. data/spec/spec_helper.rb +4 -0
  39. metadata +66 -10
  40. data/spec/dummy/config/initializers/assets.rb +0 -11
@@ -0,0 +1,98 @@
1
+ require_dependency "panda_pal/application_controller"
2
+
3
+ module PandaPal
4
+ class LtiV1P3Controller < ApplicationController
5
+ skip_before_action :verify_authenticity_token
6
+
7
+ before_action :validate_launch!, only: [:resource_link_request]
8
+ around_action :switch_tenant, only: [:resource_link_request]
9
+
10
+ def login
11
+ current_session_data[:lti_oauth_nonce] = SecureRandom.uuid
12
+
13
+ @form_action = current_lti_platform.authentication_redirect_url
14
+ @method = :post
15
+ @form_data = {
16
+ scope: 'openid',
17
+ response_type: 'id_token',
18
+ response_mode: 'form_post',
19
+ prompt: 'none',
20
+ redirect_uri: params[:target_link_uri] || v1p3_resource_link_request_url,
21
+ client_id: params.require(:client_id),
22
+ login_hint: params.require(:login_hint),
23
+ lti_message_hint: params.require(:lti_message_hint),
24
+ state: current_session.session_key,
25
+ nonce: current_session_data[:lti_oauth_nonce]
26
+ }
27
+ end
28
+
29
+ def resource_link_request
30
+ # Redirect to correct region/env?
31
+ if params[:launch_type]
32
+ current_session_data.merge!({
33
+ lti_version: 'v1p3',
34
+ lti_launch_placement: params[:launch_type],
35
+ launch_params: @decoded_lti_jwt,
36
+ })
37
+
38
+ redirect_with_session_to(:"#{LaunchUrlHelpers.launch_route(params[:launch_type])}_url", route_context: main_app)
39
+ end
40
+ end
41
+
42
+ def tool_config
43
+ if PandaPal.lti_environments.empty?
44
+ render plain: 'Domains must be set in lti_environments'
45
+ return
46
+ end
47
+
48
+ platform = PandaPal.lti_options.delete(:platform) || 'canvas.instructure.com'
49
+ request_url = "#{request.scheme}://#{request.host_with_port}"
50
+ parsed_request_url = URI.parse(request_url)
51
+
52
+ mapped_placements = PandaPal.lti_paths.map do |k, opts|
53
+ opts = opts.dup
54
+ opts.delete(:route_helper_key)
55
+ opts.merge!({
56
+ placement: k,
57
+ target_link_uri: LaunchUrlHelpers.absolute_launch_url(k.to_sym, host: parsed_request_url, launch_handler: v1p3_resource_link_request_path),
58
+ })
59
+ opts
60
+ end
61
+
62
+ config_json = {
63
+ title: PandaPal.lti_options[:title],
64
+ scopes: [],
65
+ public_jwk_url: v1p3_public_jwks_url,
66
+ description: PandaPal.lti_options[:description] || 'PandaPal LTI',
67
+ target_link_uri: v1p3_resource_link_request_url, #app_url(:resource_link_request, request),
68
+ oidc_initiation_url: v1p3_oidc_login_url,
69
+ extensions: [{
70
+ platform: platform,
71
+ privacy_level: "public",
72
+ settings: {
73
+ placements: mapped_placements,
74
+ environments: PandaPal.lti_environments,
75
+ },
76
+ }],
77
+ custom_fields: PandaPal.lti_custom_params, # PandaPal.lti_options[:custom_fields],
78
+ }
79
+
80
+ render json: config_json
81
+ end
82
+
83
+ def public_jwks
84
+ render json: {
85
+ keys: [JWT::JWK.new(PandaPal.lti_private_key).export]
86
+ }
87
+ end
88
+
89
+ private
90
+
91
+ def auth_redirect_query
92
+ return unless params[:target_link_uri]&.include? 'platform_redirect_url='
93
+
94
+ platform_redirect_url = Rack::Utils.parse_query(URI(params[:target_link_uri]).query)&.dig('platform_redirect_url')
95
+ "?platform_redirect_url=#{platform_redirect_url}"
96
+ end
97
+ end
98
+ end
@@ -76,7 +76,7 @@ module LtiXml
76
76
 
77
77
  def add_lti_nav
78
78
  PandaPal.lti_paths.each do |k, v|
79
- @tc.set_ext_param(platform, k.to_sym, ext_params(v))
79
+ @tc.set_ext_param(platform, k.to_sym, ext_params(v, k))
80
80
  end
81
81
  end
82
82
 
@@ -84,9 +84,9 @@ module LtiXml
84
84
  @tc.set_ext_param(platform, :environments, PandaPal.lti_environments)
85
85
  end
86
86
 
87
- def ext_params(options)
88
- url = options.delete(:url)
89
- options[:url] = [parsed_request_url.to_s, main_app.send([url, '_url'].join, only_path: true)].join
87
+ def ext_params(options, k)
88
+ options.delete(:route_helper_key)
89
+ options[:url] = PandaPal::LaunchUrlHelpers.absolute_launch_url(k.to_sym, host: parsed_request_url, launch_handler: nil)
90
90
  options
91
91
  end
92
92
  end
@@ -0,0 +1,69 @@
1
+ module PandaPal
2
+ module LaunchUrlHelpers
3
+ def self.absolute_launch_url(launch_type, host:, launch_handler: nil)
4
+ opts = PandaPal.lti_paths[launch_type]
5
+ final_url = launch_url(opts, launch_type: launch_type)
6
+
7
+ is_direct = opts[:route_helper_key].present? || !launch_handler.present?
8
+
9
+ if is_direct
10
+ return final_url if URI.parse(final_url).absolute?
11
+ return [host.to_s, final_url].join
12
+ else
13
+ launch_handler = resolve_route(launch_handler) if launch_handler.is_a?(Symbol)
14
+ return add_url_params([host.to_s, launch_handler].join, {
15
+ launch_type: launch_type,
16
+ })
17
+ end
18
+ end
19
+
20
+ def self.launch_url(opts, launch_type: nil)
21
+ url = launch_route(opts, launch_type: launch_type)
22
+ url = resolve_url_symbol(url) if url.is_a?(Symbol)
23
+ url
24
+ end
25
+
26
+ def self.launch_route(opts, launch_type: nil)
27
+ if opts.is_a?(Symbol) || opts.is_a?(String)
28
+ launch_type = opts.to_sym
29
+ opts = PandaPal.lti_paths[launch_type]
30
+ end
31
+
32
+ if opts[:route_helper_key]
33
+ opts[:route_helper_key].to_sym
34
+ else
35
+ opts[:url] ||= launch_type
36
+ opts[:url]
37
+ end
38
+ end
39
+
40
+ def self.resolve_url_symbol(sym, **opts)
41
+ sym = :"#{sym}_url" unless sym.to_s.ends_with?('_url')
42
+ opts[:only_path] = true unless opts.key?(:only_path)
43
+ resolve_route(:"MainApp/#{sym}", **opts)
44
+ end
45
+
46
+ def self.add_url_params(url, params)
47
+ uri = URI(url)
48
+ decoded_params = URI.decode_www_form(uri.query || "")
49
+ params.each do |k, v|
50
+ decoded_params << [k, v]
51
+ end
52
+ uri.query = URI.encode_www_form(decoded_params)
53
+ uri.to_s
54
+ end
55
+
56
+ private
57
+
58
+ def self.resolve_route(key, *arguments, engine: 'PandaPal', **kwargs)
59
+ return key if key.is_a?(String)
60
+
61
+ key_bits = key.to_s.split('/')
62
+ key_bits.prepend(engine) if key_bits.count == 1
63
+ key_bits[0] = key_bits[0].classify
64
+
65
+ engine = key_bits[0] == 'MainApp' ? Rails.application : (key_bits[0].constantize)::Engine
66
+ engine.routes.url_helpers.send(key_bits[1], *arguments, **kwargs)
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,88 @@
1
+ module PandaPal
2
+ class LtiJwtValidator
3
+ attr_reader :errors
4
+
5
+ def initialize(jwt, client_id)
6
+ @jwt = jwt
7
+ @client_id = client_id
8
+ @errors = []
9
+ end
10
+
11
+ def valid?
12
+ verify_audience(@jwt)
13
+ verify_sub(@jwt)
14
+ verify_nonce(@jwt)
15
+ verify_issued_at(@jwt)
16
+ verify_expiration(@jwt)
17
+ errors.empty?
18
+ end
19
+
20
+ private
21
+
22
+ def verify_audience(jwt)
23
+ aud = jwt[:aud]
24
+ aud_array = [*aud]
25
+ errors << 'Audience must be a string or Array of strings.' unless aud_array.all? { |a| a.is_a? String }
26
+ if jwt.key? :azp
27
+ verify_azp(aud, jwt[:azp])
28
+ else
29
+ errors << 'Audience not found' unless public_key_matches_one_of_client_ids(aud)
30
+ end
31
+ end
32
+
33
+ def verify_azp(aud, azp)
34
+ errors << 'Audience does not contain/match Authorized Party' unless azp_in_aud(aud, azp)
35
+ errors << 'Audience does not match Platform client_id' unless public_key_matches_one_of_client_ids(aud)
36
+ end
37
+
38
+ def verify_sub(jwt)
39
+ errors << 'Subject not present' if jwt[:sub].blank?
40
+ end
41
+
42
+ def verify_issued_at(jwt)
43
+ lower_bound = issued_at_lower_bound
44
+ errors << "Issued at of #{jwt[:iat]} not between #{lower_bound.to_i} and #{Time.zone.now.to_i}" unless Time.zone.at(
45
+ jwt[:iat]
46
+ ).between?(
47
+ lower_bound, Time.zone.now
48
+ )
49
+ end
50
+
51
+ def verify_expiration(jwt)
52
+ now = Time.zone.now
53
+ errors << "Expiration time of #{jwt[:exp]} before #{now.to_i}" unless Time.zone.at(jwt[:exp]) > Time.zone.now
54
+ end
55
+
56
+ def verify_nonce(jwt)
57
+ unless jwt[:nonce].present?
58
+ errors << 'Nonce not present'
59
+ return
60
+ end
61
+
62
+ return unless defined?(Redis) && Redis.current
63
+
64
+ cache_key = "nonces/#{jwt[:nonce]}"
65
+ if Redis.current.get(cache_key)
66
+ errors << 'Nonce has been used already'
67
+ else
68
+ Redis.current.set(cache_key, Time.zone.now.to_i, ex: 5.minutes)
69
+ end
70
+ end
71
+
72
+ def azp_in_aud(aud, azp)
73
+ if aud.is_a? Array
74
+ aud.include? azp
75
+ else
76
+ aud == azp
77
+ end
78
+ end
79
+
80
+ def issued_at_lower_bound
81
+ ENV.key?('issued_at_minutes_ago') ? ENV['issued_at_minutes_ago'].minutes.ago : 10.minutes.ago
82
+ end
83
+
84
+ def public_key_matches_one_of_client_ids(aud)
85
+ (Array(aud) & Array(@client_id)).any?
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,13 @@
1
+ module PandaPal
2
+ module MiscHelper
3
+ MigrationClass = Rails.version < '5.0' ? ActiveRecord::Migration : ActiveRecord::Migration[4.2]
4
+
5
+ def self.to_boolean(v)
6
+ if Rails.version < '5.0'
7
+ ActiveRecord::Type::Boolean.new.type_cast_from_user("0")
8
+ else
9
+ ActiveRecord::Type::Boolean.new.deserialize('0')
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,24 +1,33 @@
1
1
  module PandaPal
2
+ module OrganizationConcerns; end
2
3
 
3
4
  class Organization < ActiveRecord::Base
4
- attribute :settings
5
+ include OrganizationConcerns::SettingsValidation
6
+ include OrganizationConcerns::TaskScheduling if defined?(Sidekiq.schedule)
7
+
8
+ serialize :settings, Hash
5
9
  attr_encrypted :settings, marshal: true, key: :encryption_key
6
10
  before_save {|a| a.settings = a.settings} # this is a hacky work-around to a bug where attr_encrypted is not saving settings in place
11
+
7
12
  validates :key, uniqueness: { case_sensitive: false }, presence: true
8
13
  validates :secret, presence: true
9
14
  validates :name, uniqueness: { case_sensitive: false }, presence: true, format: { with: /\A[a-z0-9_]+\z/i }
10
15
  validates :canvas_account_id, presence: true
11
16
  validates :salesforce_id, presence: true, uniqueness: true
12
- validate :validate_settings
17
+
13
18
  after_create :create_schema
14
19
  after_commit :destroy_schema, on: :destroy
15
20
 
21
+ if defined?(scheduled_task)
22
+ scheduled_task '0 0 3 * * *', :clean_old_sessions do
23
+ PandaPal::Session.where(panda_pal_organization: self).where('updated_at < ?', 1.week.ago).delete_all
24
+ end
25
+ end
26
+
16
27
  before_validation on: [:update] do
17
28
  errors.add(:name, 'should not be changed after creation') if name_changed?
18
29
  end
19
30
 
20
- serialize :settings, Hash
21
-
22
31
  def encryption_key
23
32
  # production environment might not have loaded secret_key_base yet.
24
33
  # In that case, just read it from env.
@@ -29,6 +38,14 @@ module PandaPal
29
38
  end
30
39
  end
31
40
 
41
+ def switch_tenant(&block)
42
+ if block_given?
43
+ Apartment::Tenant.switch(name, &block)
44
+ else
45
+ Apartment::Tenant.switch!(name)
46
+ end
47
+ end
48
+
32
49
  private
33
50
 
34
51
  def create_schema
@@ -38,48 +55,5 @@ module PandaPal
38
55
  def destroy_schema
39
56
  Apartment::Tenant.drop name
40
57
  end
41
-
42
- def validate_settings
43
- record = self
44
- if PandaPal.lti_options && PandaPal.lti_options[:settings_structure]
45
- validate_level(record, PandaPal.lti_options[:settings_structure], record.settings, [])
46
- end
47
- end
48
- def validate_level(record, expectation, reality, previous_keys)
49
- # Verify that the data elements at this level conform to requirements.
50
- if expectation
51
- expectation.each do |key, value|
52
- is_required = expectation[key].try(:delete, :is_required)
53
- data_type = expectation[key].try(:delete, :data_type)
54
- value = reality.is_a?(Hash) ? reality.dig(key) : reality
55
-
56
- if is_required && !value
57
- record.errors[:settings] << "PandaPal::Organization.settings requires key [:#{previous_keys.push(key).join("][:")}]. It was not found."
58
- end
59
- if data_type && value
60
- if value.class.to_s != data_type
61
- record.errors[:settings] << "PandaPal::Organization.settings expected key [:#{previous_keys.push(key).join("][:")}] to be #{data_type} but it was instead #{value.class}."
62
- end
63
- end
64
- end
65
- end
66
- # Verify that anything that is in the real settings has an expectation.
67
- if reality
68
- if reality.is_a? Hash
69
- reality.each do |key, value|
70
- was_expected = expectation.has_key?(key)
71
- if !was_expected
72
- record.errors[:settings] << "PandaPal::Organization.settings had unexpected key: #{key}. If settings have expanded please update your lti_options accordingly."
73
- end
74
- end
75
- end
76
- end
77
- # Recursively check out any children settings as well.
78
- if expectation
79
- expectation.each do |key, value|
80
- validate_level(record, expectation[key], (reality.is_a?(Hash) ? reality.dig(key) : nil), previous_keys.deep_dup.push(key))
81
- end
82
- end
83
- end
84
58
  end
85
59
  end
@@ -0,0 +1,127 @@
1
+ module PandaPal
2
+ module OrganizationConcerns
3
+ module SettingsValidation
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ validate :validate_settings
8
+ end
9
+
10
+ class_methods do
11
+ def settings_structure
12
+ if PandaPal.lti_options&.[](:settings_structure).present?
13
+ normalize_settings_structure(PandaPal.lti_options[:settings_structure])
14
+ else
15
+ {
16
+ type: Hash,
17
+ allow_additional: true,
18
+ properties: {},
19
+ }
20
+ end
21
+ end
22
+
23
+ def normalize_settings_structure(struc)
24
+ return {} unless struc.present?
25
+ return struc if struc[:properties] || struc[:type] || struc.key?(:required)
26
+
27
+ struc = struc.dup
28
+ nstruc = {}
29
+
30
+ nstruc[:type] = struc.delete(:data_type) if struc.key?(:data_type)
31
+ nstruc[:required] = struc.delete(:is_required) if struc.key?(:is_required)
32
+ nstruc[:properties] = struc.map { |k, sub| [k, normalize_settings_structure(sub)] }.to_h if struc.present?
33
+
34
+ nstruc
35
+ end
36
+ end
37
+
38
+ def settings_structure
39
+ self.class.settings_structure
40
+ end
41
+
42
+ def validate_settings
43
+ validate_settings_level(settings || {}, settings_structure).each do |err|
44
+ errors[:settings] << err
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def validate_settings_level(settings, spec, path: [], errors: [])
51
+ human_path = "[:#{path.join('][:')}]"
52
+
53
+ if settings.nil?
54
+ errors << "Entry #{human_path} is required" if spec[:required]
55
+ return errors
56
+ end
57
+
58
+ if spec[:type]
59
+ norm_types = Array(spec[:type]).map do |t|
60
+ if [:bool, :boolean, 'Bool', 'Boolean'].include?(t)
61
+ 'Boolean'
62
+ elsif t.is_a?(String)
63
+ t.constantize
64
+ else
65
+ t
66
+ end
67
+ end
68
+
69
+ any_match = norm_types.any? do |t|
70
+ if t == 'Boolean'
71
+ settings == true || settings == false
72
+ else
73
+ settings.is_a?(t)
74
+ end
75
+ end
76
+
77
+ unless any_match
78
+ errors << "Expected #{human_path} to be a #{spec[:type]}. Was a #{settings.class.to_s}"
79
+ return errors
80
+ end
81
+ end
82
+
83
+ if spec[:validate].present?
84
+ val_errors = []
85
+ if spec[:validate].is_a?(Symbol)
86
+ proc_result = send(spec[:validate], settings, spec, path: path, errors: val_errors)
87
+ elsif spec[:validate].is_a?(String)
88
+ split_val = spec[:validate].split?('.')
89
+ split_val << 'validate_settings' if split_val.count == 1
90
+ resolved_module = split_val[0].constantize
91
+ proc_result = resolved_module.send(split_val[1].to_sym, settings, spec, path: path, errors: val_errors)
92
+ elsif spec[:validate].is_a?(Proc)
93
+ proc_result = instance_exec(settings, spec, path: path, errors: val_errors, &spec[:validate])
94
+ end
95
+ val_errors << proc_result unless val_errors.present? || proc_result == val_errors
96
+ val_errors = val_errors.flatten.uniq.compact.map do |ve|
97
+ ve.gsub('<path>', human_path)
98
+ end
99
+ errors.concat(val_errors)
100
+ end
101
+
102
+ if settings.is_a?(Hash)
103
+ if spec[:properties] != nil
104
+ spec[:properties].each do |key, pspec|
105
+ validate_settings_level(settings[key], pspec, path: [*path, key], errors: errors)
106
+ end
107
+ end
108
+
109
+ if spec[:properties] != nil || spec[:allow_additional] != nil
110
+ extra_keys = settings.keys - (spec[:properties]&.keys || [])
111
+ if extra_keys.present?
112
+ if spec[:allow_additional].is_a?(Hash)
113
+ extra_keys.each do |key|
114
+ validate_settings_level(settings[key], spec[:allow_additional], path: [*path, key], errors: errors)
115
+ end
116
+ elsif !spec[:allow_additional]
117
+ errors << "Did not expect #{human_path} to contain [#{extra_keys.join(', ')}]"
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ errors
124
+ end
125
+ end
126
+ end
127
+ end