panda_pal 5.1.0 → 5.2.4

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 +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