panda_pal 5.0.0.beta.2 → 5.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +204 -107
  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 +11 -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 +137 -90
  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 -12
  40. data/app/views/panda_pal/lti/iframe_cookie_authorize.html.erb +0 -19
  41. data/app/views/panda_pal/lti/iframe_cookie_fix.html.erb +0 -12
  42. 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,11 @@
1
+ module PandaPal
2
+ module MiscHelper
3
+ def self.to_boolean(v)
4
+ if Rails.version < '5.0'
5
+ ActiveRecord::Type::Boolean.new.type_cast_from_user("0")
6
+ else
7
+ ActiveRecord::Type::Boolean.new.deserialize('0')
8
+ end
9
+ end
10
+ end
11
+ 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