Class: PandaPal::Session

Inherits:
PandaPalRecord show all
Defined in:
app/models/panda_pal/session.rb

Defined Under Namespace

Classes: DataSerializer, RoleStore, SessionNonceMismatch

Class Method Summary collapse

Instance Method Summary collapse

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_role_labels(account = 'self') ⇒ Object

Retrieve the User’s Role Labels in the specified Account, defaulting to the Root Account



243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'app/models/panda_pal/session.rb', line 243

def ( = 'self')
   = 'self' if .to_s == "root"
   = .canvas_id if .respond_to?(:canvas_id)

  if defined?(::Admin) && ::Admin < ::ActiveRecord::Base
     = current_organization. if  == 'self'
    adm_query = ::Admin.where(canvas_account_id: , workflow_state: "active", canvas_user_id: canvas_user_id)
    adm_query.pluck(:role_name)
  else
    Rails.cache.fetch([self.class.name, "AccountAdminLinks", , canvas_user_id], expires_in: 1.hour) do
      admin_entries = canvas_sync_client.(, user_id: [canvas_user_id])
      admin_entries = admin_entries.select{|ent| ent[:workflow_state] == 'active' }
      admin_entries.map{|ent| ent[:role] }
    end
  end
end

#canvas_role_labelsObject

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

Returns:

  • (Boolean)


274
275
276
# File 'app/models/panda_pal/session.rb', line 274

def canvas_site_admin?
  lti_roles.system_roles.include?("sys_admin")
end

#canvas_user_idObject



264
265
266
# File 'app/models/panda_pal/session.rb', line 264

def canvas_user_id
  get_lti_cust_param('canvas_user_id')
end

#custom_lti_paramsObject



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_paramsObject



186
187
188
# File 'app/models/panda_pal/session.rb', line 186

def launch_params
  data[:launch_params] || {}
end

#lti_launch_placementObject



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_platformObject



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_rolesObject



260
261
262
# File 'app/models/panda_pal/session.rb', line 260

def lti_roles
  @lti_roles ||= RoleStore.new(launch_params["https://purl.imsglobal.org/spec/lti/claim/roles"] || launch_params['ext_roles'] || '')
end

#session_keyObject



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)
  OpenSSL::HMAC.base64digest(OpenSSL::Digest.new('sha256'), signing_key, data)
end

#signing_keyObject



150
151
152
# File 'app/models/panda_pal/session.rb', line 150

def signing_key
  session_secret
end

#userObject



268
269
270
271
272
# File 'app/models/panda_pal/session.rb', line 268

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