panda_pal 5.1.0 → 5.2.0

Sign up to get free protection for your applications and to get access to all the features.
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 +11 -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 +138 -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 +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,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,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,65 @@ 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)
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
+
75
197
  private
198
+
76
199
  def organization_key
77
- 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
78
204
  end
79
205
 
80
206
  def organization_id
81
207
  params[:organization_id]
82
208
  end
83
209
 
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
210
  def session_key_header
93
211
  if match = request.headers['Authorization'].try(:match, /token=(.+)/)
94
212
  match[1]
95
213
  end
96
214
  end
97
215
 
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))
216
+ def panda_pal_cryptor
217
+ @panda_pal_cryptor ||= ActiveSupport::MessageEncryptor.new(Rails.application.secret_key_base[0..31])
120
218
  end
121
219
 
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))
220
+ def auto_save_session
221
+ yield if block_given?
222
+ save_session if @current_session && session_changed?
129
223
  end
130
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