panda_pal 5.0.0.beta.3 → 5.2.1

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 (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 +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 +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
@@ -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,40 @@
1
1
  require 'browser'
2
2
 
3
3
  module PandaPal::Helpers::ControllerHelper
4
+ extend ActiveSupport::Concern
5
+
6
+ class SessionNotFound < 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
10
- @current_session ||= PandaPal::Session.new(panda_pal_organization_id: current_organization.id)
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] == params[:session_token]
26
+ @current_session = matched_session
27
+ @current_session.data[:link_nonce] = nil
28
+ end
29
+ elsif (session_key = params[:session_key] || session_key_header || flash[:session_key] || session[:session_key]).present?
30
+ @current_session = PandaPal::Session.find_by(session_key: session_key) if session_key.present?
31
+ else
32
+ @current_session = PandaPal::Session.new(panda_pal_organization_id: current_organization.id)
33
+ end
34
+
35
+ raise SessionNotFound, "Session Not Found" unless @current_session.present?
36
+
37
+ @current_session
11
38
  end
12
39
 
13
40
  def current_organization
@@ -16,17 +43,41 @@ module PandaPal::Helpers::ControllerHelper
16
43
  @organization ||= PandaPal::Organization.find_by_name(Apartment::Tenant.current)
17
44
  end
18
45
 
46
+ def current_lti_platform
47
+ return @current_lti_platform if @current_lti_platform.present?
48
+ # TODO: (Future) This could be expanded more to take better advantage of the LTI 1.3 Multi-Tenancy model.
49
+ if (canvas_url = current_organization&.settings&.dig(:canvas, :base_url)).present?
50
+ @current_lti_platform ||= PandaPal::Platform::Canvas.new(canvas_url)
51
+ end
52
+ @current_lti_platform ||= PandaPal::Platform::Canvas.new('http://localhost:3000') if Rails.env.development?
53
+ @current_lti_platform ||= PandaPal::Platform::CANVAS
54
+ @current_lti_platform
55
+ end
56
+
19
57
  def current_session_data
20
58
  current_session.data
21
59
  end
22
60
 
61
+ def lti_launch_params
62
+ current_session_data[:launch_params]
63
+ end
64
+
23
65
  def session_changed?
24
66
  current_session.changed? && current_session.changes[:data].present?
25
67
  end
26
68
 
27
69
  def validate_launch!
28
- authorized = false
29
70
  safari_override
71
+
72
+ if params[:id_token].present?
73
+ validate_v1p3_launch
74
+ elsif params[:oauth_consumer_key].present?
75
+ validate_v1p0_launch
76
+ end
77
+ end
78
+
79
+ def validate_v1p0_launch
80
+ authorized = false
30
81
  if @organization = params['oauth_consumer_key'] && PandaPal::Organization.find_by_key(params['oauth_consumer_key'])
31
82
  sanitized_params = request.request_parameters
32
83
  # These params come over with a safari-workaround launch. The authenticator doesn't like them, so clean them out.
@@ -37,32 +88,42 @@ module PandaPal::Helpers::ControllerHelper
37
88
  authenticator = IMS::LTI::Services::MessageAuthenticator.new(request.original_url, sanitized_params, @organization.secret)
38
89
  authorized = authenticator.valid_signature?
39
90
  end
40
- # short-circuit if we know the user is not authorized.
91
+
41
92
  if !authorized
42
93
  render plain: 'Invalid Credentials, please contact your Administrator.', :status => :unauthorized unless authorized
43
- return authorized
44
- end
45
- if require_persistent_session
46
- if cookies_need_iframe_fix?(false)
47
- fix_iframe_cookies
48
- return false
49
- end
50
- # For safari we may have been launched temporarily full-screen by canvas. This allows us to set the session cookie.
51
- # In this case, we should make sure the session cookie is fixed and redirect back to canvas to properly launch the embedded LTI.
52
- if params[:platform_redirect_url]
53
- session[:safari_cookie_fixed] = true
54
- redirect_to params[:platform_redirect_url]
55
- return false
56
- end
57
94
  end
58
- return authorized
95
+
96
+ authorized
59
97
  end
60
98
 
61
- def require_persistent_session
62
- if PandaPal.lti_options.has_key?(:require_persistent_session) && PandaPal.lti_options[:require_persistent_session] == true
63
- return true
64
- end
65
- return false
99
+ def validate_v1p3_launch
100
+ decoded_jwt = JSON::JWT.decode(params.require(:id_token), :skip_verification)
101
+ raise JSON::JWT::VerificationFailed, 'error decoding id_token' if decoded_jwt.blank?
102
+
103
+ client_id = decoded_jwt['aud']
104
+ @organization = PandaPal::Organization.find_by!(key: 'PandaPal') # client_id)
105
+ raise JSON::JWT::VerificationFailed, 'Unrecognized Organization' unless @organization.present?
106
+
107
+ decoded_jwt.verify!(current_lti_platform.public_jwks)
108
+
109
+ params[:session_key] = params[:state]
110
+ raise JSON::JWT::VerificationFailed, 'State is invalid' unless current_session_data[:lti_oauth_nonce] == decoded_jwt['nonce']
111
+
112
+ jwt_verifier = PandaPal::LtiJwtValidator.new(decoded_jwt, client_id)
113
+ raise JSON::JWT::VerificationFailed, jwt_verifier.errors unless jwt_verifier.valid?
114
+
115
+ @decoded_lti_jwt = decoded_jwt
116
+ rescue JSON::JWT::VerificationFailed => e
117
+ payload = Array(e.message)
118
+
119
+ render json: {
120
+ message: [
121
+ { errors: payload },
122
+ { id_token: params.require(:id_token) },
123
+ ],
124
+ }, status: :unauthorized
125
+
126
+ false
66
127
  end
67
128
 
68
129
  def switch_tenant(organization = current_organization, &block)
@@ -74,38 +135,18 @@ module PandaPal::Helpers::ControllerHelper
74
135
  end
75
136
  end
76
137
 
77
- # Browsers that prevent 3rd party cookies by default (Safari and IE) run into problems
78
- # with CSRF handling because the Rails session cookie isn't set. To fix this, we
79
- # redirect the current page to the LTI using JavaScript, which will set the cookie,
80
- # and then immediately redirect back to Canvas.
81
- def fix_iframe_cookies
82
- if params[:safari_cookie_authorized].present?
83
- session[:safari_cookie_authorized] = true
84
- redirect_to params[:return_to]
85
- elsif (session[:safari_cookie_fixed] && !params[:safari_cookie_authorized])
86
- render 'panda_pal/lti/iframe_cookie_authorize', layout: false
87
- else
88
- render 'panda_pal/lti/iframe_cookie_fix', layout: false
89
- end
90
- end
91
-
92
- def cookies_need_iframe_fix?(check_authorized=true)
93
- if check_authorized
94
- return browser.safari? && !request.referrer&.include?('sessionless_launch') && !(session[:safari_cookie_fixed] && session[:safari_cookie_authorized]) && !params[:platform_redirect_url]
95
- else
96
- return browser.safari? && !request.referrer&.include?('sessionless_launch') && !session[:safari_cookie_fixed] && !params[:platform_redirect_url]
97
- end
98
- end
99
-
100
138
  def forbid_access_if_lacking_session
101
- if require_persistent_session && cookies_need_iframe_fix?(true)
102
- fix_iframe_cookies
103
- else
104
- render plain: 'You should do an LTI Tool Launch.', status: :unauthorized unless valid_session?
105
- end
139
+ render plain: 'You should do an LTI Tool Launch.', status: :unauthorized unless valid_session?
106
140
  safari_override
107
141
  end
108
142
 
143
+ def verify_authenticity_token
144
+ # No need to check CSRF when no cookies were sent. This fixes CSRF failures in Browsers
145
+ # that restrict Cookie setting within an IFrame.
146
+ return unless request.cookies.keys.length > 0
147
+ super
148
+ end
149
+
109
150
  def valid_session?
110
151
  [
111
152
  current_session.persisted?,
@@ -119,59 +160,65 @@ module PandaPal::Helpers::ControllerHelper
119
160
  use_secure_headers_override(:safari_override) if browser.safari?
120
161
  end
121
162
 
163
+ # Redirect with the session key intact. In production,
164
+ # handle this by adding a one-time use encrypted token to the URL.
165
+ # Keeping it in the URL in development means that it plays
166
+ # nicely with webpack-dev-server live reloading (otherwise
167
+ # you get an access error everytime it tries to live reload).
168
+
169
+ def redirect_with_session_to(location, params = {}, route_context: self)
170
+ if Rails.env.development?
171
+ redirect_to route_context.send(location, {
172
+ session_key: current_session.session_key,
173
+ organization_id: current_organization.id,
174
+ }.merge(params))
175
+ else
176
+ redirect_to route_context.send(location, {
177
+ session_token: link_nonce,
178
+ organization_id: current_organization.id,
179
+ }.merge(params))
180
+ end
181
+ end
182
+
183
+ def link_nonce
184
+ @link_nonce ||= begin
185
+ current_session_data[:link_nonce] = SecureRandom.hex
186
+
187
+ payload = {
188
+ session_key: current_session.session_key,
189
+ organization_id: current_organization.id,
190
+ nonce: current_session_data[:link_nonce],
191
+ }
192
+
193
+ panda_pal_cryptor.encrypt_and_sign(payload.to_json)
194
+ end
195
+ end
196
+
122
197
  private
198
+
123
199
  def organization_key
124
- params[:oauth_consumer_key] || session[:organization_key]
200
+ org_key ||= params[:oauth_consumer_key]
201
+ org_key ||= "#{params[:client_id]}/#{params[:deployment_id]}" if params[:client_id].present?
202
+ org_key ||= session[:organization_key]
203
+ org_key
125
204
  end
126
205
 
127
206
  def organization_id
128
207
  params[:organization_id]
129
208
  end
130
209
 
131
- def session_key
132
- if params[:encrypted_session_key]
133
- crypt = ActiveSupport::MessageEncryptor.new(Rails.application.secret_key_base[0..31])
134
- return crypt.decrypt_and_verify(params[:encrypted_session_key])
135
- end
136
- params[:session_key] || session_key_header || flash[:session_key] || session[:session_key]
137
- end
138
-
139
210
  def session_key_header
140
211
  if match = request.headers['Authorization'].try(:match, /token=(.+)/)
141
212
  match[1]
142
213
  end
143
214
  end
144
215
 
145
- # Redirect with the session key intact. In production,
146
- # handle this by encrypting the session key. That way if the
147
- # url is logged anywhere, it will all be encrypted data. In dev,
148
- # just put it in the URL. Putting it in the URL
149
- # is insecure, but is fine in development.
150
- # Keeping it in the URL in development means that it plays
151
- # nicely with webpack-dev-server live reloading (otherwise
152
- # you get an access error everytime it tries to live reload).
153
-
154
- def redirect_with_session_to(location, params = {})
155
- if Rails.env.development?
156
- redirect_development_mode(location, params)
157
- else
158
- redirect_production_mode(location, params)
159
- end
160
- end
161
-
162
- def redirect_development_mode(location, params)
163
- redirect_to send(location, {
164
- session_key: current_session.session_key,
165
- organization_id: current_organization.id
166
- }.merge(params))
216
+ def panda_pal_cryptor
217
+ @panda_pal_cryptor ||= ActiveSupport::MessageEncryptor.new(Rails.application.secret_key_base[0..31])
167
218
  end
168
219
 
169
- def redirect_production_mode(location, params)
170
- crypt = ActiveSupport::MessageEncryptor.new(Rails.application.secret_key_base[0..31])
171
- encrypted_data = crypt.encrypt_and_sign(current_session.session_key)
172
- redirect_to send(location, {
173
- encrypted_session_key: encrypted_data,
174
- organization_id: current_organization.id
175
- }.merge(params))
220
+ def auto_save_session
221
+ yield if block_given?
222
+ save_session if @current_session && session_changed?
176
223
  end
177
224
  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