Class: PandaPal::Session
- Inherits:
-
PandaPalRecord
- Object
- ActiveRecord::Base
- PandaPalRecord
- PandaPal::Session
- Defined in:
- app/models/panda_pal/session.rb
Defined Under Namespace
Classes: DataSerializer, RoleStore, SessionNonceMismatch
Class Method Summary collapse
- .extract_panda_token(request, params = request.params) ⇒ Object
- .for_panda_token(panda_token, request: nil, validate: true, enforce_tenant: true) ⇒ Object
- .for_request(request, params = request.params, enforce_tenant: true) ⇒ Object
- .format_url_for_signing(uri) ⇒ Object
Instance Method Summary collapse
- #[](key) ⇒ Object
- #[]=(key, value) ⇒ Object
- #cache(key, &blk) ⇒ Object
-
#canvas_account_ancestry(account) ⇒ Object
accountplus every Account above it, nearest first. -
#canvas_account_role_labels(account = 'self', inherited: false) ⇒ Object
Retrieve the User's Role Labels in the specified Account.
-
#canvas_role_labels ⇒ Object
Retrieve the User's Role Labels in the Launch Context.
- #canvas_site_admin? ⇒ Boolean
- #canvas_user_id ⇒ Object
- #custom_lti_params ⇒ Object
- #get_lti_cust_param(key, default: :if_not_var) ⇒ Object
- #launch_params ⇒ Object
- #lti_launch_placement ⇒ Object
- #lti_platform ⇒ Object
- #lti_roles ⇒ Object
- #session_key ⇒ Object
- #sign_value(data) ⇒ Object
- #signing_key ⇒ Object
- #user ⇒ Object
- #validate_signature(data, signature) ⇒ Object
Class Method Details
.extract_panda_token(request, params = request.params) ⇒ Object
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
# File 'app/models/panda_pal/session.rb', line 107 def self.extract_panda_token(request, params = request.params) headers = request.respond_to?(:headers) ? request.headers : request.env token = headers['HTTP_X_PANDA_TOKEN'] || headers['X-Panda-Token'] token ||= begin if (auth_header = headers['HTTP_AUTHORIZATION'] || headers['Authorization']).present? match = auth_header.match(/Bearer panda:(.+)/) match ||= auth_header.match(/token=(.+)/) # Legacy Support token = match[1] if match && match[1].include?('.') end end token ||= params["panda_token"] token ||= params["session_token"] if params["session_token"]&.include?('.') # Legacy Support token ||= params["session_key"] if params["session_key"]&.include?('.') # Legacy Support token.presence end |
.for_panda_token(panda_token, request: nil, validate: true, enforce_tenant: true) ⇒ Object
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
# File 'app/models/panda_pal/session.rb', line 21 def self.for_panda_token(panda_token, request: nil, validate: true, enforce_tenant: true) return nil unless panda_token.present? # <B64({ v: TOKEN FORMAT, o: ORG ID, s: SESSION ID, typ: TYPE, exp?: EXPIRE, usig?: URL SIGNATURE, nnc?: NONCE })>.<HMAC(<B64HEADER>) | KEY> # TYPE = KEY | T-URI | T-IP | T-NONCE | T-EXP found_session = nil header, sig = panda_token.split('.') decoded_header = JSON.parse(Base64.urlsafe_decode64(header)) org_id = decoded_header['o'].to_i session_id = decoded_header['s'] type = decoded_header['typ'] tenant = "public" if org_id > 0 org = PandaPal::Organization.find(org_id) tenant = org.tenant_name end Apartment::Tenant.switch!(tenant) if enforce_tenant == :switch if enforce_tenant && Apartment::Tenant.current != tenant raise SessionNonceMismatch, "Session Not Found" end Apartment::Tenant.switch(tenant) do if type == 'KEY' found_session = PandaPal::Session.find_by(session_secret: sig) return found_session unless validate raise SessionNonceMismatch, "Session Not Found" unless found_session.present? raise SessionNonceMismatch, "Session Not Found" if session_id && found_session.id != session_id else session_record = PandaPal::Session.find_by(id: session_id) return session_record unless validate raise SessionNonceMismatch, "Session Not Found" unless session_record.present? # Validate the header against the signature raise SessionNonceMismatch, "Invalid Signature" unless session_record.validate_signature(header, sig) # Check expiration if (expr = decoded_header['exp']).present? expr = Time.parse(expr).utc raise SessionNonceMismatch, "Expired" unless expr > DateTime.now.utc end if type == "T-URI" || type == "T-URL" # Signed URLs only support GET requests raise SessionNonceMismatch, "Invalid Method" unless request.method.to_s.upcase == "GET" # Verify the signature against the request URL # The usig doesn't _need_ to be signed (since it's part of the payload that is already signed), # but this was an easy way to make the value a constant length. Any basic hashing function could solve this, # but the HMAC implementation was already available. to_sign = format_url_for_signing(request.url) raise SessionNonceMismatch, "Invalid Signature" unless session_record.validate_signature(to_sign, decoded_header['usig']) # TODO Support single-use tokens via Redis? found_session = session_record elsif type == "T-NONCE" raise SessionNonceMismatch, "Invalid Nonce" unless session_record.data[:link_nonce] == decoded_header['nnc'] found_session = session_record found_session.data[:link_nonce] = nil elsif type == "T-IP" raise SessionNonceMismatch, "Invalid IP" unless decoded_header["ip"] == request.remote_ip found_session = session_record elsif type == "T-EXP" # Expiration itself is checked above, but enforce that an expiration is set raise SessionNonceMismatch, "Invalid Expiration" unless decoded_header["exp"].present? found_session = session_record else raise SessionNonceMismatch, "Invalid Panda Token Type" end end end raise SessionNonceMismatch, "Session Not Found" unless found_session.present? found_session end |
.for_request(request, params = request.params, enforce_tenant: true) ⇒ Object
14 15 16 17 18 19 |
# File 'app/models/panda_pal/session.rb', line 14 def self.for_request(request, params = request.params, enforce_tenant: true) return request.instance_variable_get(:@panda_pal_session) if request.instance_variable_defined?(:@panda_pal_session) panda_token = extract_panda_token(request, params) request.instance_variable_set(:@panda_pal_session, for_panda_token(panda_token, request: request, enforce_tenant: enforce_tenant)) end |
.format_url_for_signing(uri) ⇒ Object
126 127 128 129 130 131 132 133 134 135 136 |
# File 'app/models/panda_pal/session.rb', line 126 def self.format_url_for_signing(uri) uri = URI.parse(uri) if uri.is_a?(String) query = Rack::Utils.parse_query(uri.query) query.delete("panda_token") query.delete("session_key") query.delete("session_token") uri.query = Rack::Utils.build_query(query) "#{uri.path}?#{uri.query}" end |
Instance Method Details
#[](key) ⇒ Object
163 164 165 166 167 168 169 |
# File 'app/models/panda_pal/session.rb', line 163 def [](key) if self.class.column_names.include?(key.to_s) super else data[key] end end |
#[]=(key, value) ⇒ Object
171 172 173 174 175 176 177 |
# File 'app/models/panda_pal/session.rb', line 171 def []=(key, value) if self.class.column_names.include?(key.to_s) super else data[key] = value end end |
#cache(key, &blk) ⇒ Object
179 180 181 182 183 184 |
# File 'app/models/panda_pal/session.rb', line 179 def cache(key, &blk) data[:cache] ||= {} return data[:cache][key] if data[:cache].key?(key) data[:cache][key] = blk.call end |
#canvas_account_ancestry(account) ⇒ Object
account plus every Account above it, nearest first.
Reads the local Account mirror when the host app has one — no API traffic,
and it works whether or not that model uses the ancestry gem, since only
canvas_parent_account_id is needed. Falls back to walking Canvas.
Traversal is topological and deliberately does NOT skip deleted Accounts:
dropping one would silently truncate the chain and deny an Admin above it.
Whether a role still counts is decided by the Admin record's own
workflow_state, not by the shape of the tree.
291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 |
# File 'app/models/panda_pal/session.rb', line 291 def canvas_account_ancestry(account) ids = [account] seen = Set.new([account.to_s]) parent_of = if defined?(::Account) && ::Account < ::ActiveRecord::Base ->(id) { ::Account.find_by(canvas_id: id)&.canvas_parent_account_id } else ->(id) { canvas_sync_client.account(id)[:parent_account_id] } end current = account while (parent = parent_of.call(current)).present? break if seen.include?(parent.to_s) # cycle guard — never trust remote topology ids << parent seen << parent.to_s current = parent end ids end |
#canvas_account_role_labels(account = 'self', inherited: false) ⇒ Object
Retrieve the User's Role Labels in the specified Account.
account accepts an Account-like object (anything responding to
canvas_id), a Canvas Account id, or one of:
['self'] the Account this Tool is installed on (the default). [:root] a synonym for 'self'. In an LTI context the installed Account IS the root of everything the Tool can see, and that — deliberately — need not be Canvas's own root Account: a Tool installed on a sub-account has no business reasoning about Accounts above its install.
With inherited: true, roles held on any ANCESTOR of account are
included too, because a Canvas Account Admin administers their Account and
everything beneath it — so an Admin above account genuinely holds that
role over account. Off by default: a caller asking "which roles are
granted AT this Account" would not expect inherited ones, so opting in is
explicit, as CanvasSync's canvas_account_admin? does.
260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 |
# File 'app/models/panda_pal/session.rb', line 260 def canvas_account_role_labels(account = 'self', inherited: false) account = 'self' if account.to_s == "root" account = account.canvas_id if account.respond_to?(:canvas_id) if defined?(::Admin) && ::Admin < ::ActiveRecord::Base account = panda_pal_organization.canvas_account_id if account == 'self' accounts = inherited ? canvas_account_ancestry(account) : [account] adm_query = ::Admin.where(canvas_account_id: accounts, workflow_state: "active", canvas_user_id: canvas_user_id) adm_query.pluck(:role_name) else Rails.cache.fetch([self.class.name, "AccountAdminLinks", account, inherited, canvas_user_id], expires_in: 1.hour) do accounts = inherited ? canvas_account_ancestry(account) : [account] accounts.flat_map { |acct| admin_entries = canvas_sync_client.account_admins(acct, user_id: [canvas_user_id]) admin_entries = admin_entries.select{|ent| ent[:workflow_state] == 'active' } admin_entries.map{|ent| ent[:role] } }.uniq end end end |
#canvas_role_labels ⇒ Object
Retrieve the User's Role Labels in the Launch Context
237 238 239 240 |
# File 'app/models/panda_pal/session.rb', line 237 def canvas_role_labels labels = get_lti_cust_param('custom_canvas_role') labels.is_a?(String) ? labels.split(',') : [] end |
#canvas_site_admin? ⇒ Boolean
328 329 330 |
# File 'app/models/panda_pal/session.rb', line 328 def canvas_site_admin? lti_roles.system_roles.include?("sys_admin") end |
#canvas_user_id ⇒ Object
318 319 320 |
# File 'app/models/panda_pal/session.rb', line 318 def canvas_user_id get_lti_cust_param('canvas_user_id') end |
#custom_lti_params ⇒ Object
200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 |
# File 'app/models/panda_pal/session.rb', line 200 def custom_lti_params @custom_lti_params ||= begin # LT 1.3 custom_params = launch_params[PandaPal::LtiConstants::Claims::CUSTOM] return custom_params if custom_params.present? # LTI 1.0/1.1 custom_params = {} launch_params.each do |k, v| next unless k.start_with?("custom_") custom_params[k[7..-1]] = v end custom_params.with_indifferent_access end end |
#get_lti_cust_param(key, default: :if_not_var) ⇒ Object
217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 |
# File 'app/models/panda_pal/session.rb', line 217 def get_lti_cust_param(key, default: :if_not_var) nkey = key.to_s.gsub(/^custom_/, '') default_value = ->() { PandaPal.lti_custom_params[nkey] || PandaPal.lti_custom_params["custom_#{nkey}"] } val = launch_params.dig("https://purl.imsglobal.org/spec/lti/claim/custom", nkey) || launch_params[nkey] || launch_params["custom_#{nkey}"] if default == :if_not_var if val.is_a?(String) && /\$[\.\w]+/.match?(val) && val == default_value[] return nil end elsif default && !val.present? return default_value[] elsif !default && val == default_value[] return nil end val end |
#launch_params ⇒ Object
186 187 188 |
# File 'app/models/panda_pal/session.rb', line 186 def launch_params data[:launch_params] || {} end |
#lti_launch_placement ⇒ Object
196 197 198 |
# File 'app/models/panda_pal/session.rb', line 196 def lti_launch_placement launch_params['https://www.instructure.com/placement'] || launch_params[:launch_type] end |
#lti_platform ⇒ Object
190 191 192 193 194 |
# File 'app/models/panda_pal/session.rb', line 190 def lti_platform return nil unless data[:lti_platform].present? @lti_platform ||= Platform.from_serialized(data[:lti_platform]) end |
#lti_roles ⇒ Object
314 315 316 |
# File 'app/models/panda_pal/session.rb', line 314 def lti_roles @lti_roles ||= RoleStore.new(launch_params["https://purl.imsglobal.org/spec/lti/claim/roles"] || launch_params['ext_roles'] || '') end |
#session_key ⇒ Object
138 139 140 141 142 143 144 145 146 147 148 |
# File 'app/models/panda_pal/session.rb', line 138 def session_key # <B64({ v: TOKEN FORMAT, o: ORG ID, s: SESSION ID, typ: "KEY" })>.<SESSION SECRET> payload = { v: 1, o: panda_pal_organization_id, s: id, typ: "KEY", } base64_payload = Base64.urlsafe_encode64(payload.to_json) "#{base64_payload}.#{session_secret}" end |
#sign_value(data) ⇒ Object
154 155 156 |
# File 'app/models/panda_pal/session.rb', line 154 def sign_value(data) Base64.strict_encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), signing_key, data)) end |
#signing_key ⇒ Object
150 151 152 |
# File 'app/models/panda_pal/session.rb', line 150 def signing_key session_secret end |
#user ⇒ Object
322 323 324 325 326 |
# File 'app/models/panda_pal/session.rb', line 322 def user return nil unless canvas_user_id.present? @user ||= ::User.find_by(canvas_id: canvas_user_id) if defined?(::User) && ::User < ::ActiveRecord::Base end |
#validate_signature(data, signature) ⇒ Object
158 159 160 161 |
# File 'app/models/panda_pal/session.rb', line 158 def validate_signature(data, signature) # TODO Constant time signature == sign_value(data) end |