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
@@ -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.secrets.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.secrets.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