panda_pal 5.1.0 → 5.2.4

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +139 -93
  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 +6 -3
  11. data/app/models/panda_pal/{organization → organization_concerns}/settings_validation.rb +21 -5
  12. data/app/models/panda_pal/{organization → organization_concerns}/task_scheduling.rb +32 -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 -43
  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 +3 -2
  32. metadata +32 -8
@@ -7,11 +7,11 @@ module PandaPal
7
7
  class NotMounted < StandardError;end
8
8
 
9
9
  @@lti_navigation = {}
10
- @@staged_navigation = {}
11
10
  @@lti_options = {}
12
11
  @@lti_properties = {}
13
12
  @@lti_environments = {}
14
13
  @@lti_custom_params = {}
14
+ @@lti_private_key = nil
15
15
 
16
16
  def self.lti_options= lti_options
17
17
  @@lti_options = lti_options
@@ -45,31 +45,44 @@ module PandaPal
45
45
  @@lti_custom_params.deep_dup
46
46
  end
47
47
 
48
- def self.register_navigation(navigation)
49
- @@lti_navigation[navigation] ||= {}
50
- end
51
-
52
48
  def self.stage_navigation(navigation, options)
53
- @@staged_navigation[navigation] = {} unless @@staged_navigation.has_key?(navigation)
54
- @@staged_navigation[navigation].merge!(options)
49
+ @@lti_navigation[navigation] ||= {}
50
+ @@lti_navigation[navigation].merge!(options)
55
51
  end
56
52
 
57
53
  def self.lti_paths
58
54
  @@lti_navigation.deep_dup
59
55
  end
60
56
 
61
- def self.propagate_lti_navigation
62
- @@staged_navigation.each do |k,v|
63
- lti_navigation(k,v)
64
- @@staged_navigation.delete(k)
65
- end
57
+ def self.lti_private_key
58
+ key = @@lti_private_key.presence
59
+ key ||= ENV['LTI_PRIVATE_KEY'].presence
60
+ key ||= File.read(File.join( File.dirname(__FILE__), "../config/dev_lti_key.key")) if Rails.env.development?
61
+ return nil unless key.present?
62
+
63
+ key = OpenSSL::PKey::RSA.new(key) if key.is_a?(String)
64
+ key
65
+ end
66
+
67
+ def self.lti_private_key=(v)
68
+ @@lti_private_key = k
66
69
  end
67
70
 
68
71
  private
69
72
 
70
- def self.lti_navigation(navigation, options)
71
- raise "lti navigation '#{navigation}' has not been registered!" unless @@lti_navigation.has_key?(navigation)
72
- @@lti_navigation[navigation].merge!(options)
73
+ def self.validate_pandapal_config!
74
+ errors = []
75
+ validate_lti_navigation(errors)
76
+ if errors.present?
77
+ lines = errors.map { |e| " - #{e}" }
78
+ raise "PandaPal was not configured correctly:\n#{lines.join("\n")}"
79
+ end
73
80
  end
74
81
 
82
+ def self.validate_lti_navigation(errors = [])
83
+ @@lti_navigation.each do |k, v|
84
+ errors << "lti navigation '#{k}' does not have a Route!" unless (LaunchUrlHelpers.launch_url(k) rescue nil)
85
+ end
86
+ errors
87
+ end
75
88
  end
@@ -38,52 +38,21 @@ module PandaPal
38
38
  initializer 'panda_pal.route_options' do |app|
39
39
  ActiveSupport.on_load(:action_controller) do
40
40
  Rails.application.reload_routes!
41
- PandaPal::propagate_lti_navigation
41
+ PandaPal::validate_pandapal_config!
42
42
  end
43
43
  end
44
44
 
45
45
  initializer :secure_headers do |app|
46
- connect_src = %w('self')
47
- script_src = %w('self')
48
-
49
- if Rails.env.development?
50
- # Allow webpack-dev-server to work
51
- connect_src << "http://localhost:3035"
52
- connect_src << "ws://localhost:3035"
53
-
54
- # Allow stuff like rack-mini-profiler to work in development:
55
- # https://github.com/MiniProfiler/rack-mini-profiler/issues/327
56
- # DON'T ENABLE THIS FOR PRODUCTION!
57
- script_src << "'unsafe-eval'"
58
- end
59
-
60
- SecureHeaders::Configuration.default do |config|
61
- # The default cookie headers aren't compatable with PandaPal cookies currenntly
62
- config.cookies = { samesite: { none: true } }
63
-
64
- if Rails.env.production?
65
- config.cookies[:secure] = true
46
+ begin
47
+ ::SecureHeaders::Configuration.default do |config|
48
+ PandaPal::SecureHeaders.apply_defaults(config)
66
49
  end
67
-
68
- # Need to allow LTI iframes
69
- config.x_frame_options = "ALLOWALL"
70
-
71
- config.x_content_type_options = "nosniff"
72
- config.x_xss_protection = "1; mode=block"
73
- config.referrer_policy = %w(origin-when-cross-origin strict-origin-when-cross-origin)
74
-
75
- config.csp = {
76
- default_src: %w('self'),
77
- script_src: script_src,
78
- # Certain CSS-in-JS libraries inline the CSS, so we need to use unsafe-inline for them
79
- style_src: %w('self' 'unsafe-inline' blob: https://fonts.googleapis.com),
80
- font_src: %w('self' data: https://fonts.gstatic.com),
81
- connect_src: connect_src,
82
- }
50
+ rescue ::SecureHeaders::Configuration::AlreadyConfiguredError
51
+ # The App already applied settings
83
52
  end
84
53
 
85
- SecureHeaders::Configuration.override(:safari_override) do |config|
86
- config.cookies = SecureHeaders::OPT_OUT
54
+ ::SecureHeaders::Configuration.override(:safari_override) do |config|
55
+ config.cookies = ::SecureHeaders::OPT_OUT
87
56
  end
88
57
  end
89
58
  end
@@ -1,4 +1,5 @@
1
1
  module PandaPal::Helpers
2
2
  require_relative 'helpers/controller_helper'
3
3
  require_relative 'helpers/route_helper'
4
+ require_relative 'helpers/secure_headers'
4
5
  end
@@ -1,13 +1,39 @@
1
1
  require 'browser'
2
2
 
3
3
  module PandaPal::Helpers::ControllerHelper
4
+ extend ActiveSupport::Concern
5
+
6
+ class SessionNonceMismatch < StandardError; end
7
+
8
+ included do
9
+ helper_method :link_nonce, :current_session
10
+
11
+ after_action :auto_save_session
12
+ end
13
+
4
14
  def save_session
5
15
  current_session.try(:save)
6
16
  end
7
17
 
8
18
  def current_session
9
- @current_session ||= PandaPal::Session.find_by(session_key: session_key) if session_key
19
+ return @current_session if @current_session.present?
20
+
21
+ if params[:session_token]
22
+ payload = JSON.parse(panda_pal_cryptor.decrypt_and_verify(params[:session_token])).with_indifferent_access
23
+ matched_session = PandaPal::Session.find_by(session_key: payload[:session_key])
24
+
25
+ if matched_session.present? && matched_session.data[:link_nonce] == payload[:nonce]
26
+ @current_session = matched_session
27
+ @current_session.data[:link_nonce] = nil
28
+ end
29
+ raise SessionNonceMismatch, "Session Not Found" unless @current_session.present?
30
+ elsif (session_key = params[:session_key] || session_key_header || flash[:session_key] || session[:session_key]).present?
31
+ @current_session = PandaPal::Session.find_by(session_key: session_key) if session_key.present?
32
+ end
33
+
10
34
  @current_session ||= PandaPal::Session.new(panda_pal_organization_id: current_organization.id)
35
+
36
+ @current_session
11
37
  end
12
38
 
13
39
  def current_organization
@@ -16,17 +42,41 @@ module PandaPal::Helpers::ControllerHelper
16
42
  @organization ||= PandaPal::Organization.find_by_name(Apartment::Tenant.current)
17
43
  end
18
44
 
45
+ def current_lti_platform
46
+ return @current_lti_platform if @current_lti_platform.present?
47
+ # TODO: (Future) This could be expanded more to take better advantage of the LTI 1.3 Multi-Tenancy model.
48
+ if (canvas_url = current_organization&.settings&.dig(:canvas, :base_url)).present?
49
+ @current_lti_platform ||= PandaPal::Platform::Canvas.new(canvas_url)
50
+ end
51
+ @current_lti_platform ||= PandaPal::Platform::Canvas.new('http://localhost:3000') if Rails.env.development?
52
+ @current_lti_platform ||= PandaPal::Platform::CANVAS
53
+ @current_lti_platform
54
+ end
55
+
19
56
  def current_session_data
20
57
  current_session.data
21
58
  end
22
59
 
60
+ def lti_launch_params
61
+ current_session_data[:launch_params]
62
+ end
63
+
23
64
  def session_changed?
24
65
  current_session.changed? && current_session.changes[:data].present?
25
66
  end
26
67
 
27
68
  def validate_launch!
28
- authorized = false
29
69
  safari_override
70
+
71
+ if params[:id_token].present?
72
+ validate_v1p3_launch
73
+ elsif params[:oauth_consumer_key].present?
74
+ validate_v1p0_launch
75
+ end
76
+ end
77
+
78
+ def validate_v1p0_launch
79
+ authorized = false
30
80
  if @organization = params['oauth_consumer_key'] && PandaPal::Organization.find_by_key(params['oauth_consumer_key'])
31
81
  sanitized_params = request.request_parameters
32
82
  # These params come over with a safari-workaround launch. The authenticator doesn't like them, so clean them out.
@@ -37,12 +87,42 @@ module PandaPal::Helpers::ControllerHelper
37
87
  authenticator = IMS::LTI::Services::MessageAuthenticator.new(request.original_url, sanitized_params, @organization.secret)
38
88
  authorized = authenticator.valid_signature?
39
89
  end
40
- # short-circuit if we know the user is not authorized.
90
+
41
91
  if !authorized
42
92
  render plain: 'Invalid Credentials, please contact your Administrator.', :status => :unauthorized unless authorized
43
- return authorized
44
93
  end
45
- return authorized
94
+
95
+ authorized
96
+ end
97
+
98
+ def validate_v1p3_launch
99
+ decoded_jwt = JSON::JWT.decode(params.require(:id_token), :skip_verification)
100
+ raise JSON::JWT::VerificationFailed, 'error decoding id_token' if decoded_jwt.blank?
101
+
102
+ client_id = decoded_jwt['aud']
103
+ @organization = PandaPal::Organization.find_by!(key: 'PandaPal') # client_id)
104
+ raise JSON::JWT::VerificationFailed, 'Unrecognized Organization' unless @organization.present?
105
+
106
+ decoded_jwt.verify!(current_lti_platform.public_jwks)
107
+
108
+ params[:session_key] = params[:state]
109
+ raise JSON::JWT::VerificationFailed, 'State is invalid' unless current_session_data[:lti_oauth_nonce] == decoded_jwt['nonce']
110
+
111
+ jwt_verifier = PandaPal::LtiJwtValidator.new(decoded_jwt, client_id)
112
+ raise JSON::JWT::VerificationFailed, jwt_verifier.errors unless jwt_verifier.valid?
113
+
114
+ @decoded_lti_jwt = decoded_jwt
115
+ rescue JSON::JWT::VerificationFailed => e
116
+ payload = Array(e.message)
117
+
118
+ render json: {
119
+ message: [
120
+ { errors: payload },
121
+ { id_token: params.require(:id_token) },
122
+ ],
123
+ }, status: :unauthorized
124
+
125
+ false
46
126
  end
47
127
 
48
128
  def switch_tenant(organization = current_organization, &block)
@@ -59,6 +139,13 @@ module PandaPal::Helpers::ControllerHelper
59
139
  safari_override
60
140
  end
61
141
 
142
+ def verify_authenticity_token
143
+ # No need to check CSRF when no cookies were sent. This fixes CSRF failures in Browsers
144
+ # that restrict Cookie setting within an IFrame.
145
+ return unless request.cookies.keys.length > 0
146
+ super
147
+ end
148
+
62
149
  def valid_session?
63
150
  [
64
151
  current_session.persisted?,
@@ -66,65 +153,74 @@ module PandaPal::Helpers::ControllerHelper
66
153
  current_session.panda_pal_organization_id == current_organization.id,
67
154
  Apartment::Tenant.current == current_organization.name
68
155
  ].all?
156
+ rescue SessionNonceMismatch
157
+ false
69
158
  end
70
159
 
71
160
  def safari_override
72
161
  use_secure_headers_override(:safari_override) if browser.safari?
73
162
  end
74
163
 
164
+ # Redirect with the session key intact. In production,
165
+ # handle this by adding a one-time use encrypted token to the URL.
166
+ # Keeping it in the URL in development means that it plays
167
+ # nicely with webpack-dev-server live reloading (otherwise
168
+ # you get an access error everytime it tries to live reload).
169
+
170
+ def redirect_with_session_to(location, params = {}, route_context: self, **rest)
171
+ params.merge!(rest)
172
+ if Rails.env.development?
173
+ redirect_to route_context.send(location, {
174
+ session_key: current_session.session_key,
175
+ organization_id: current_organization.id,
176
+ }.merge(params))
177
+ else
178
+ redirect_to route_context.send(location, {
179
+ session_token: link_nonce,
180
+ organization_id: current_organization.id,
181
+ }.merge(params))
182
+ end
183
+ end
184
+
185
+ def link_nonce
186
+ @link_nonce ||= begin
187
+ current_session_data[:link_nonce] = SecureRandom.hex
188
+
189
+ payload = {
190
+ session_key: current_session.session_key,
191
+ organization_id: current_organization.id,
192
+ nonce: current_session_data[:link_nonce],
193
+ }
194
+
195
+ panda_pal_cryptor.encrypt_and_sign(payload.to_json)
196
+ end
197
+ end
198
+
75
199
  private
200
+
76
201
  def organization_key
77
- params[:oauth_consumer_key] || session[:organization_key]
202
+ org_key ||= params[:oauth_consumer_key]
203
+ org_key ||= "#{params[:client_id]}/#{params[:deployment_id]}" if params[:client_id].present?
204
+ org_key ||= session[:organization_key]
205
+ org_key
78
206
  end
79
207
 
80
208
  def organization_id
81
209
  params[:organization_id]
82
210
  end
83
211
 
84
- def session_key
85
- if params[:encrypted_session_key]
86
- crypt = ActiveSupport::MessageEncryptor.new(Rails.application.secret_key_base[0..31])
87
- return crypt.decrypt_and_verify(params[:encrypted_session_key])
88
- end
89
- params[:session_key] || session_key_header || flash[:session_key] || session[:session_key]
90
- end
91
-
92
212
  def session_key_header
93
213
  if match = request.headers['Authorization'].try(:match, /token=(.+)/)
94
214
  match[1]
95
215
  end
96
216
  end
97
217
 
98
- # Redirect with the session key intact. In production,
99
- # handle this by encrypting the session key. That way if the
100
- # url is logged anywhere, it will all be encrypted data. In dev,
101
- # just put it in the URL. Putting it in the URL
102
- # is insecure, but is fine in development.
103
- # Keeping it in the URL in development means that it plays
104
- # nicely with webpack-dev-server live reloading (otherwise
105
- # you get an access error everytime it tries to live reload).
106
-
107
- def redirect_with_session_to(location, params = {})
108
- if Rails.env.development?
109
- redirect_development_mode(location, params)
110
- else
111
- redirect_production_mode(location, params)
112
- end
113
- end
114
-
115
- def redirect_development_mode(location, params)
116
- redirect_to send(location, {
117
- session_key: current_session.session_key,
118
- organization_id: current_organization.id
119
- }.merge(params))
218
+ def panda_pal_cryptor
219
+ @panda_pal_cryptor ||= ActiveSupport::MessageEncryptor.new(Rails.application.secret_key_base[0..31])
120
220
  end
121
221
 
122
- def redirect_production_mode(location, params)
123
- crypt = ActiveSupport::MessageEncryptor.new(Rails.application.secret_key_base[0..31])
124
- encrypted_data = crypt.encrypt_and_sign(current_session.session_key)
125
- redirect_to send(location, {
126
- encrypted_session_key: encrypted_data,
127
- organization_id: current_organization.id
128
- }.merge(params))
222
+ def auto_save_session
223
+ yield if block_given?
224
+ save_session if @current_session && session_changed?
129
225
  end
130
226
  end
@@ -1,17 +1,17 @@
1
1
  module PandaPal::Helpers::RouteHelper
2
- def lti_nav(nav, *rest, &block)
2
+ def lti_nav(options, *rest, &block)
3
3
  base_path = Rails.application.routes.named_routes[:panda_pal].path.spec
4
4
  raise LtiNavigationInUse.new('PandaPal must be mounted before defining lti_nav routes') if base_path.blank?
5
- options = nav
5
+
6
6
  nav, to = options.first
7
7
  options[:to] = to
8
8
  options.delete nav
9
- lti_options = options.delete(:lti_options) || {}
10
9
  path = "#{base_path}/#{nav.to_s}"
11
- lti_options[:url] = path.split('/').reject(&:empty?).join('_')
12
- post path, options, &block
13
- get path, options, &block
14
- PandaPal::register_navigation(nav)
15
- PandaPal::lti_navigation(nav, lti_options)
10
+
11
+ lti_options = options.delete(:lti_options) || {}
12
+ lti_options[:route_helper_key] = path.split('/').reject(&:empty?).join('_')
13
+ post(path, options.dup, &block)
14
+ get(path, options.dup, &block)
15
+ PandaPal::stage_navigation(nav, lti_options)
16
16
  end
17
17
  end
@@ -0,0 +1,79 @@
1
+ module PandaPal
2
+ module SecureHeaders
3
+ def self.apply_defaults(config)
4
+ @config = config
5
+ # The default cookie headers aren't compatable with PandaPal cookies currenntly
6
+ config.cookies = { samesite: { none: true } }
7
+
8
+ if Rails.env.production?
9
+ config.cookies[:secure] = true
10
+ end
11
+
12
+ # Need to allow LTI iframes
13
+ config.x_frame_options = "ALLOWALL"
14
+
15
+ config.x_content_type_options = "nosniff"
16
+ config.x_xss_protection = "1; mode=block"
17
+ config.referrer_policy = %w(origin-when-cross-origin strict-origin-when-cross-origin)
18
+
19
+ config.csp ||= {}
20
+
21
+ csp_entry(:default_src, %w['self'])
22
+ csp_entry(:connect_src, %w['self'])
23
+ csp_entry(:script_src, %w['self'])
24
+
25
+ if Rails.env.development?
26
+ # Allow webpack-dev-server to work
27
+ csp_entry(:connect_src, "http://localhost:3035")
28
+ csp_entry(:connect_src, "ws://localhost:3035")
29
+
30
+ # Allow stuff like rack-mini-profiler to work in development:
31
+ # https://github.com/MiniProfiler/rack-mini-profiler/issues/327
32
+ # DON'T ENABLE THIS FOR PRODUCTION!
33
+ csp_entry(:script_src, "'unsafe-eval'")
34
+
35
+ # Detect and permit Scout APM in Dev
36
+ if MiscHelper.to_boolean(ENV['SCOUT_DEV_TRACE'])
37
+ csp_entry(:default_src, 'https://scoutapm.com')
38
+ csp_entry(:default_src, 'https://apm.scoutapp.com')
39
+
40
+ csp_entry(:script_src, "'unsafe-inline'")
41
+ csp_entry(:script_src, 'https://scoutapm.com')
42
+ csp_entry(:script_src, 'https://apm.scoutapp.com')
43
+
44
+ csp_entry(:connect_src, 'https://apm.scoutapp.com')
45
+
46
+ csp_entry(:style_src, 'https://scoutapm.com')
47
+ csp_entry(:style_src, 'https://apm.scoutapp.com')
48
+ end
49
+ end
50
+
51
+ # Detect and permit Sentry
52
+ if defined?(Raven) && Raven.configuration.server.present?
53
+ csp_entry(:connect_src, Raven.configuration.server)
54
+
55
+ # Report CSP Violations to Sentry
56
+ unless config.csp[:report_uri].present?
57
+ cfg = Raven.configuration
58
+ config.csp[:report_uri] = ["#{cfg.scheme}://#{cfg.host}/api/#{cfg.project_id}/security/?sentry_key=#{cfg.public_key}"] unless config.csp[:report_uri].present?
59
+ end
60
+ end
61
+
62
+ # Certain CSS-in-JS libraries inline the CSS, so we need to use unsafe-inline for them
63
+ csp_entry(:style_src, %w('self' 'unsafe-inline' blob: https://fonts.googleapis.com))
64
+ csp_entry(:font_src, %w('self' data: https://fonts.gstatic.com))
65
+
66
+ @config = nil
67
+
68
+ config
69
+ end
70
+
71
+ private
72
+
73
+ def self.csp_entry(key, *values)
74
+ values = values.flatten
75
+ @config.csp[key] ||= []
76
+ @config.csp[key] |= values
77
+ end
78
+ end
79
+ end