panda_pal 5.2.5 → 5.3.6.beta1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc40f9ca1d3a036a82a30105f8390bfe9faf57519ca977040921999c1e97221c
4
- data.tar.gz: 01cca7d9851d83e6d2029291fde3f0baba97c1ba46e51287211b9769f803ae7f
3
+ metadata.gz: 89cb05a4553807543825da2a80fa692689cbbc594a1ed7d7087aaf91b4967039
4
+ data.tar.gz: 0603fbf7137c94639f1c43252e2ddb5510d8d620a72042587840fed8b18b3d08
5
5
  SHA512:
6
- metadata.gz: f41f229e1d8101ce9de5ba9d97ec3e8a3a96b5a11d5fa7972534df8e1d2226172db2e82c3af17f228cc48b1ceb0f8e71e6ded9ed08308c9124a45365e89cf8fa
7
- data.tar.gz: 29dbfab39a1b5445d31975306af414e336f0a3965c2fd5426e90335963d4da50238c69c7232a66248ad4f7524b7fd3444fc01d2a535ba4e1414aa2f02267b1bc
6
+ metadata.gz: c0b5747018b1a1f0fcb6193038e26905a54f4afcc9f4e698954f2ac9924082826d1ed29be76740c4ea389a2c2ccbb3d4d3d303bd0d18b3d4ba6709f7ad79b625
7
+ data.tar.gz: 0deda4bedafe6583167610e83a86b8d13bd4256094df6158971811c097c3a085345ea18ecaaedcf84cc9b0d890ca6ebf8ee6685ee6e70ada9b71370d4b4c9834
data/README.md CHANGED
@@ -369,8 +369,30 @@ You will want to watch out for a few scenarios:
369
369
  3) If you use `link_to` and navigate in your LTI (apps that are not single page)
370
370
  make sure you include the `link_nonce` like so:
371
371
  ```ruby
372
- link_to "Link Name", somewhere_else_path(session_token: link_nonce)
372
+ link_to "Link Name", somewhere_else_path(arg, session_token: link_nonce)
373
373
  ```
374
+ NB: As of PandaPal 5.2.6, you can instead use
375
+ ```ruby
376
+ link_to "Name", url_with_session(:somewhere_else_path, arg, kwarg: 1)
377
+ ```
378
+
379
+ Persistent sessions have session_tokens as a way to safely communicate a session key in a way that is hopefully not too persistent in case it is logged somewhere.
380
+ Options for communicating session_token -
381
+ :nonce (default) - each nonce is good for exactly one communication with the backend server. Once the nonce is used, it is no longer valid.
382
+ :fixed_ip - each session_token is good until it expires. It must be used from the same ip the LTI launched from.
383
+ :expiring - this is the least secure. Each token is good until it expires.
384
+
385
+ For :fixed_ip and :expiring tokens you can override the default expiration period of 15 minutes.
386
+
387
+ See the following example of how to override the link_nonce_type and token expiration length.
388
+
389
+ class ApplicationController < ActionController::Base
390
+ link_nonce_type :fixed_ip
391
+ def session_expiration_period_minutes
392
+ 120
393
+ end
394
+ ...
395
+ end
374
396
 
375
397
  ### Previous Safari Instructions
376
398
  Safari is weird and you'll potentially run into issues getting `POST` requests to properly validate CSRF if you don't do the following:
@@ -2,6 +2,8 @@ require_dependency "panda_pal/application_controller"
2
2
 
3
3
  module PandaPal
4
4
  class LtiV1P0Controller < ApplicationController
5
+ skip_forgery_protection
6
+
5
7
  def launch
6
8
  current_session_data.merge!({
7
9
  lti_version: 'v1p0',
@@ -2,8 +2,7 @@ require_dependency "panda_pal/application_controller"
2
2
 
3
3
  module PandaPal
4
4
  class LtiV1P3Controller < ApplicationController
5
- skip_before_action :verify_authenticity_token
6
-
5
+ skip_forgery_protection
7
6
  before_action :validate_launch!, only: [:resource_link_request]
8
7
  around_action :switch_tenant, only: [:resource_link_request]
9
8
 
@@ -37,6 +36,12 @@ module PandaPal
37
36
 
38
37
  redirect_with_session_to(:"#{LaunchUrlHelpers.launch_route(params[:launch_type])}_url", route_context: main_app)
39
38
  end
39
+ # render json: {
40
+ # launch_type: params[:launch_type],
41
+ # final_url: LaunchUrlHelpers.launch_url(params[:launch_type]),
42
+ # final_route: LaunchUrlHelpers.launch_route(params[:launch_type]),
43
+ # decoded_jwt: @decoded_lti_jwt,
44
+ # }
40
45
  end
41
46
 
42
47
  def tool_config
@@ -0,0 +1,41 @@
1
+ module PandaPal
2
+ # An array that "processes" after so many items are added.
3
+ #
4
+ # Example Usage:
5
+ # batches = BatchProcessor.new(of: 1000) do |batch|
6
+ # # Process the batch somehow
7
+ # end
8
+ # enumerator_of_some_kind.each { |item| batches << item }
9
+ # batches.flush
10
+ class BatchProcessor
11
+ attr_reader :batch_size
12
+
13
+ def initialize(of: 1000, &blk)
14
+ @batch_size = of
15
+ @block = blk
16
+ @current_batch = []
17
+ end
18
+
19
+ def <<(item)
20
+ @current_batch << item
21
+ process_batch if @current_batch.count >= batch_size
22
+ end
23
+
24
+ def add_all(items)
25
+ items.each do |i|
26
+ self << i
27
+ end
28
+ end
29
+
30
+ def flush
31
+ process_batch if @current_batch.present?
32
+ end
33
+
34
+ protected
35
+
36
+ def process_batch
37
+ @block.call(@current_batch)
38
+ @current_batch = []
39
+ end
40
+ end
41
+ end
@@ -4,9 +4,9 @@ module PandaPal
4
4
 
5
5
  def self.to_boolean(v)
6
6
  if Rails.version < '5.0'
7
- ActiveRecord::Type::Boolean.new.type_cast_from_user("0")
7
+ ActiveRecord::Type::Boolean.new.type_cast_from_user(v)
8
8
  else
9
- ActiveRecord::Type::Boolean.new.deserialize('0')
9
+ ActiveRecord::Type::Boolean.new.deserialize(v)
10
10
  end
11
11
  end
12
12
  end
@@ -40,41 +40,7 @@ module PandaPal
40
40
 
41
41
  hash.tap do |hash|
42
42
  kl = ' ' * (k.to_s.length - 4)
43
- hash[k.to_sym] = hash[k.to_s] = {
44
- required: false,
45
- description: <<~MARKDOWN,
46
- Override schedule for '#{k.to_s}' task.
47
-
48
- **Default**: #{desc[:schedule].is_a?(String) ? desc[:schedule] : '<Computed>'}
49
-
50
- Set to `false` to disable or supply a Cron string:
51
- ```yaml
52
- #{k.to_s}: 0 0 0 * * * America/Denver
53
- ##{kl} │ │ │ │ │ │ └── Timezone (Optional)
54
- ##{kl} │ │ │ │ │ └── Day of Week
55
- ##{kl} │ │ │ │ └── Month
56
- ##{kl} │ │ │ └── Day of Month
57
- ##{kl} │ │ └── Hour
58
- ##{kl} │ └── Minute
59
- ##{kl} └── Second (Optional)
60
- ````
61
- MARKDOWN
62
- json_schema: {
63
- oneOf: [
64
- { type: 'string', pattern: '^((((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ?){5,6})(\w+\/\w+)?$' },
65
- { enum: [false] },
66
- ],
67
- default: desc[:schedule].is_a?(String) ? desc[:schedule] : '0 0 3 * * * America/Denver',
68
- },
69
- validate: ->(value, *args, errors:, **kwargs) {
70
- begin
71
- Rufus::Scheduler.parse(value) if value
72
- nil
73
- rescue ArgumentError
74
- errors << "<path> must be false or a Crontab string"
75
- end
76
- }
77
- }
43
+ hash[k.to_sym] = hash[k.to_s] = PandaPal::OrganizationConcerns::TaskScheduling.build_settings_entry(desc)
78
44
  end
79
45
  end,
80
46
  }
@@ -137,6 +103,51 @@ module PandaPal
137
103
  end
138
104
  end
139
105
 
106
+ def self.build_settings_entry(desc)
107
+ k = desc[:key]
108
+ kl = ' ' * (k.to_s.length - 4)
109
+
110
+ default_schedule = '<Computed>'
111
+ default_schedule = desc[:schedule] if desc[:schedule].is_a?(String)
112
+ default_schedule = '<Disabled>' unless desc[:schedule].present?
113
+
114
+ {
115
+ required: false,
116
+ description: <<~MARKDOWN,
117
+ Override schedule for '#{k.to_s}' task.
118
+
119
+ **Default**: #{default_schedule}
120
+
121
+ Set to `false` to disable or supply a Cron string:
122
+ ```yaml
123
+ #{k.to_s}: 0 0 0 * * * America/Denver
124
+ ##{kl} │ │ │ │ │ │ └── Timezone (Optional)
125
+ ##{kl} │ │ │ │ │ └── Day of Week
126
+ ##{kl} │ │ │ │ └── Month
127
+ ##{kl} │ │ │ └── Day of Month
128
+ ##{kl} │ │ └── Hour
129
+ ##{kl} │ └── Minute
130
+ ##{kl} └── Second (Optional)
131
+ ````
132
+ MARKDOWN
133
+ json_schema: {
134
+ oneOf: [
135
+ { type: 'string', pattern: '^((((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ?){5,6})(\w+\/\w+)?$' },
136
+ { enum: [false] },
137
+ ],
138
+ default: desc[:schedule].is_a?(String) ? desc[:schedule] : '0 0 3 * * * America/Denver',
139
+ },
140
+ validate: ->(value, *args, errors:, **kwargs) {
141
+ begin
142
+ Rufus::Scheduler.parse(value) if value
143
+ nil
144
+ rescue ArgumentError
145
+ errors << "<path> must be false or a Crontab string"
146
+ end
147
+ }
148
+ }
149
+ end
150
+
140
151
  private
141
152
 
142
153
  def unschedule_tasks(new_task_keys = nil)
@@ -19,7 +19,6 @@ class RemoveOldOrganizationSettings < PandaPal::MiscHelper::MigrationClass
19
19
  #PandaPal::Organization.reset_column_information
20
20
  PandaPal::Organization.find_each do |o|
21
21
  # Would like to just be able to do this:
22
- # PandaPal::Organization.reset_column_information
23
22
  # o.settings = YAML.load(o.old_settings)
24
23
  # o.save!
25
24
  # but for some reason that is always making the settings null. Instead we will encrypt the settings manually.
@@ -1,226 +1,139 @@
1
1
  require 'browser'
2
+ require_relative 'session_replacement'
2
3
 
3
- module PandaPal::Helpers::ControllerHelper
4
- extend ActiveSupport::Concern
4
+ module PandaPal::Helpers
5
+ module ControllerHelper
6
+ extend ActiveSupport::Concern
7
+ include SessionReplacement
5
8
 
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
-
14
- def save_session
15
- current_session.try(:save)
16
- end
17
-
18
- def current_session
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])
9
+ def current_organization
10
+ @organization ||= PandaPal::Organization.find_by!(key: organization_key) if organization_key
11
+ @organization ||= PandaPal::Organization.find_by(id: organization_id) if organization_id
12
+ @organization ||= PandaPal::Organization.find_by_name(Apartment::Tenant.current)
13
+ end
24
14
 
25
- if matched_session.present? && matched_session.data[:link_nonce] == payload[:nonce]
26
- @current_session = matched_session
27
- @current_session.data[:link_nonce] = nil
15
+ def current_lti_platform
16
+ return @current_lti_platform if @current_lti_platform.present?
17
+ # TODO: (Future) This could be expanded more to take better advantage of the LTI 1.3 Multi-Tenancy model.
18
+ if (canvas_url = current_organization&.settings&.dig(:canvas, :base_url)).present?
19
+ @current_lti_platform ||= PandaPal::Platform::Canvas.new(canvas_url)
28
20
  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?
21
+ @current_lti_platform ||= PandaPal::Platform::Canvas.new('http://localhost:3000') if Rails.env.development?
22
+ @current_lti_platform ||= PandaPal::Platform::CANVAS
23
+ @current_lti_platform
32
24
  end
33
25
 
34
- @current_session ||= PandaPal::Session.new(panda_pal_organization_id: current_organization.id)
35
-
36
- @current_session
37
- end
38
-
39
- def current_organization
40
- @organization ||= PandaPal::Organization.find_by!(key: organization_key) if organization_key
41
- @organization ||= PandaPal::Organization.find_by(id: organization_id) if organization_id
42
- @organization ||= PandaPal::Organization.find_by_name(Apartment::Tenant.current)
43
- end
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)
26
+ def lti_launch_params
27
+ current_session_data[:launch_params]
50
28
  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
-
56
- def current_session_data
57
- current_session.data
58
- end
59
-
60
- def lti_launch_params
61
- current_session_data[:launch_params]
62
- end
63
-
64
- def session_changed?
65
- current_session.changed? && current_session.changes[:data].present?
66
- end
67
29
 
68
- def validate_launch!
69
- safari_override
30
+ def validate_launch!
31
+ safari_override
70
32
 
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
80
- if @organization = params['oauth_consumer_key'] && PandaPal::Organization.find_by_key(params['oauth_consumer_key'])
81
- sanitized_params = request.request_parameters
82
- # These params come over with a safari-workaround launch. The authenticator doesn't like them, so clean them out.
83
- safe_unexpected_params = ["full_win_launch_requested", "platform_redirect_url", "dummy_param"]
84
- safe_unexpected_params.each do |p|
85
- sanitized_params.delete(p)
33
+ if params[:id_token].present?
34
+ validate_v1p3_launch
35
+ elsif params[:oauth_consumer_key].present?
36
+ validate_v1p0_launch
86
37
  end
87
- authenticator = IMS::LTI::Services::MessageAuthenticator.new(request.original_url, sanitized_params, @organization.secret)
88
- authorized = authenticator.valid_signature?
89
38
  end
90
39
 
91
- if !authorized
92
- render plain: 'Invalid Credentials, please contact your Administrator.', :status => :unauthorized unless authorized
93
- end
94
-
95
- authorized
96
- end
40
+ def validate_v1p0_launch
41
+ authorized = false
42
+ if @organization = params['oauth_consumer_key'] && PandaPal::Organization.find_by_key(params['oauth_consumer_key'])
43
+ sanitized_params = request.request_parameters
44
+ # These params come over with a safari-workaround launch. The authenticator doesn't like them, so clean them out.
45
+ safe_unexpected_params = ["full_win_launch_requested", "platform_redirect_url", "dummy_param"]
46
+ safe_unexpected_params.each do |p|
47
+ sanitized_params.delete(p)
48
+ end
49
+ authenticator = IMS::LTI::Services::MessageAuthenticator.new(request.original_url, sanitized_params, @organization.secret)
50
+ authorized = authenticator.valid_signature?
51
+ end
97
52
 
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?
53
+ if !authorized
54
+ render plain: 'Invalid Credentials, please contact your Administrator.', :status => :unauthorized unless authorized
55
+ end
101
56
 
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?
57
+ authorized
58
+ end
105
59
 
106
- decoded_jwt.verify!(current_lti_platform.public_jwks)
60
+ def validate_v1p3_launch
61
+ decoded_jwt = JSON::JWT.decode(params.require(:id_token), :skip_verification)
62
+ raise JSON::JWT::VerificationFailed, 'error decoding id_token' if decoded_jwt.blank?
107
63
 
108
- params[:session_key] = params[:state]
109
- raise JSON::JWT::VerificationFailed, 'State is invalid' unless current_session_data[:lti_oauth_nonce] == decoded_jwt['nonce']
64
+ client_id = decoded_jwt['aud']
65
+ @organization = PandaPal::Organization.find_by!(key: client_id)
66
+ raise JSON::JWT::VerificationFailed, 'Unrecognized Organization' unless @organization.present?
110
67
 
111
- jwt_verifier = PandaPal::LtiJwtValidator.new(decoded_jwt, client_id)
112
- raise JSON::JWT::VerificationFailed, jwt_verifier.errors unless jwt_verifier.valid?
68
+ decoded_jwt.verify!(current_lti_platform.public_jwks)
113
69
 
114
- @decoded_lti_jwt = decoded_jwt
115
- rescue JSON::JWT::VerificationFailed => e
116
- payload = Array(e.message)
70
+ params[:session_key] = params[:state]
71
+ raise JSON::JWT::VerificationFailed, 'State is invalid' unless current_session_data[:lti_oauth_nonce] == decoded_jwt['nonce']
117
72
 
118
- render json: {
119
- message: [
120
- { errors: payload },
121
- { id_token: params.require(:id_token) },
122
- ],
123
- }, status: :unauthorized
73
+ jwt_verifier = PandaPal::LtiJwtValidator.new(decoded_jwt, client_id)
74
+ raise JSON::JWT::VerificationFailed, jwt_verifier.errors unless jwt_verifier.valid?
124
75
 
125
- false
126
- end
76
+ @decoded_lti_jwt = decoded_jwt
77
+ rescue JSON::JWT::VerificationFailed => e
78
+ payload = Array(e.message)
127
79
 
128
- def switch_tenant(organization = current_organization, &block)
129
- return unless organization
130
- raise 'This method should be called in an around_action callback' unless block_given?
80
+ render json: {
81
+ message: [
82
+ { errors: payload },
83
+ { id_token: params.require(:id_token) },
84
+ ],
85
+ }, status: :unauthorized
131
86
 
132
- Apartment::Tenant.switch(organization.name) do
133
- yield
87
+ false
134
88
  end
135
- end
136
89
 
137
- def forbid_access_if_lacking_session
138
- render plain: 'You should do an LTI Tool Launch.', status: :unauthorized unless valid_session?
139
- safari_override
140
- end
90
+ def switch_tenant(organization = current_organization, &block)
91
+ return unless organization
92
+ raise 'This method should be called in an around_action callback' unless block_given?
141
93
 
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
-
149
- def valid_session?
150
- [
151
- current_session.persisted?,
152
- current_organization,
153
- current_session.panda_pal_organization_id == current_organization.id,
154
- Apartment::Tenant.current == current_organization.name
155
- ].all?
156
- rescue SessionNonceMismatch
157
- false
158
- end
159
-
160
- def safari_override
161
- use_secure_headers_override(:safari_override) if browser.safari?
162
- end
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))
94
+ Apartment::Tenant.switch(organization.name) do
95
+ yield
96
+ end
182
97
  end
183
- end
184
-
185
- def link_nonce
186
- @link_nonce ||= begin
187
- current_session_data[:link_nonce] = SecureRandom.hex
188
98
 
189
- payload = {
190
- session_key: current_session.session_key,
191
- organization_id: current_organization.id,
192
- nonce: current_session_data[:link_nonce],
193
- }
99
+ def forbid_access_if_lacking_session
100
+ super
101
+ safari_override
102
+ end
194
103
 
195
- panda_pal_cryptor.encrypt_and_sign(payload.to_json)
104
+ def valid_session?
105
+ return false unless current_session(create_missing: false)&.persisted?
106
+ return false unless current_organization
107
+ return false unless current_session.panda_pal_organization_id == current_organization.id
108
+ return false unless Apartment::Tenant.current == current_organization.name
109
+ true
110
+ rescue SessionNonceMismatch
111
+ false
196
112
  end
197
- end
198
113
 
199
- private
114
+ def safari_override
115
+ use_secure_headers_override(:safari_override) if browser.safari?
116
+ end
200
117
 
201
- def 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
206
- end
118
+ private
207
119
 
208
- def organization_id
209
- params[:organization_id]
210
- end
211
-
212
- def session_key_header
213
- if match = request.headers['Authorization'].try(:match, /token=(.+)/)
214
- match[1]
120
+ def find_or_create_session(key:)
121
+ if key == :create
122
+ PandaPal::Session.new(panda_pal_organization_id: current_organization.id)
123
+ else
124
+ PandaPal::Session.find_by(session_key: key)
125
+ end
215
126
  end
216
- end
217
127
 
218
- def panda_pal_cryptor
219
- @panda_pal_cryptor ||= ActiveSupport::MessageEncryptor.new(Rails.application.secret_key_base[0..31])
220
- end
128
+ def organization_key
129
+ org_key ||= params[:oauth_consumer_key]
130
+ org_key ||= "#{params[:client_id]}/#{params[:deployment_id]}" if params[:client_id].present?
131
+ org_key ||= session[:organization_key]
132
+ org_key
133
+ end
221
134
 
222
- def auto_save_session
223
- yield if block_given?
224
- save_session if @current_session && session_changed?
135
+ def organization_id
136
+ params[:organization_id]
137
+ end
225
138
  end
226
139
  end