panda_pal 5.0.0 → 5.2.3

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 +64 -8
  40. 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] == payload[:nonce]
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,12 +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
94
  end
45
- return authorized
95
+
96
+ authorized
97
+ end
98
+
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
46
127
  end
47
128
 
48
129
  def switch_tenant(organization = current_organization, &block)
@@ -59,6 +140,13 @@ module PandaPal::Helpers::ControllerHelper
59
140
  safari_override
60
141
  end
61
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
+
62
150
  def valid_session?
63
151
  [
64
152
  current_session.persisted?,
@@ -72,59 +160,66 @@ module PandaPal::Helpers::ControllerHelper
72
160
  use_secure_headers_override(:safari_override) if browser.safari?
73
161
  end
74
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, **rest)
170
+ params.merge!(rest)
171
+ if Rails.env.development?
172
+ redirect_to route_context.send(location, {
173
+ session_key: current_session.session_key,
174
+ organization_id: current_organization.id,
175
+ }.merge(params))
176
+ else
177
+ redirect_to route_context.send(location, {
178
+ session_token: link_nonce,
179
+ organization_id: current_organization.id,
180
+ }.merge(params))
181
+ end
182
+ end
183
+
184
+ def link_nonce
185
+ @link_nonce ||= begin
186
+ current_session_data[:link_nonce] = SecureRandom.hex
187
+
188
+ payload = {
189
+ session_key: current_session.session_key,
190
+ organization_id: current_organization.id,
191
+ nonce: current_session_data[:link_nonce],
192
+ }
193
+
194
+ panda_pal_cryptor.encrypt_and_sign(payload.to_json)
195
+ end
196
+ end
197
+
75
198
  private
199
+
76
200
  def organization_key
77
- params[:oauth_consumer_key] || session[:organization_key]
201
+ org_key ||= params[:oauth_consumer_key]
202
+ org_key ||= "#{params[:client_id]}/#{params[:deployment_id]}" if params[:client_id].present?
203
+ org_key ||= session[:organization_key]
204
+ org_key
78
205
  end
79
206
 
80
207
  def organization_id
81
208
  params[:organization_id]
82
209
  end
83
210
 
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
211
  def session_key_header
93
212
  if match = request.headers['Authorization'].try(:match, /token=(.+)/)
94
213
  match[1]
95
214
  end
96
215
  end
97
216
 
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))
217
+ def panda_pal_cryptor
218
+ @panda_pal_cryptor ||= ActiveSupport::MessageEncryptor.new(Rails.application.secret_key_base[0..31])
120
219
  end
121
220
 
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))
221
+ def auto_save_session
222
+ yield if block_given?
223
+ save_session if @current_session && session_changed?
129
224
  end
130
225
  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
@@ -1,3 +1,3 @@
1
1
  module PandaPal
2
- VERSION = "5.0.0"
2
+ VERSION = "5.2.3"
3
3
  end
@@ -15,13 +15,17 @@ Gem::Specification.new do |s|
15
15
  s.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md", "panda_pal.gemspec"]
16
16
  s.test_files = Dir["spec/**/*"]
17
17
 
18
- s.add_dependency "rails", ">= 5.1.0"
18
+ s.add_dependency "rails", ">= 4.2"
19
19
  s.add_dependency 'pg', '>= 0.20', '< 1.0.0'
20
20
  s.add_dependency 'apartment', '~> 2.2.0'
21
21
  s.add_dependency 'ims-lti', '~> 2.2.3'
22
22
  s.add_dependency 'browser', '2.5.0'
23
23
  s.add_dependency 'attr_encrypted', '~> 3.0.0'
24
- s.add_dependency 'secure_headers', '~> 6.1.2'
24
+ s.add_dependency 'secure_headers', '~> 6.1'
25
+ s.add_dependency 'json-jwt'
26
+
27
+ s.add_development_dependency 'sidekiq'
28
+ s.add_development_dependency 'sidekiq-scheduler'
25
29
  s.add_development_dependency 'rspec-rails'
26
30
  s.add_development_dependency 'factory_girl_rails'
27
31
  end