panda_pal 5.2.5 → 5.3.6.beta1

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