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



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'app/models/panda_pal/session.rb', line 106

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
# 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?
        raise SessionNonceMismatch, "Expired" unless expr > DateTime.now.iso8601
      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



125
126
127
128
129
130
131
132
133
134
135
# File 'app/models/panda_pal/session.rb', line 125

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



162
163
164
165
166
167
168
# File 'app/models/panda_pal/session.rb', line 162

def [](key)
  if self.class.column_names.include?(key.to_s)
    super
  else
    data[key]
  end
end

#[]=(key, value) ⇒ Object



170
171
172
173
174
175
176
# File 'app/models/panda_pal/session.rb', line 170

def []=(key, value)
  if self.class.column_names.include?(key.to_s)
    super
  else
    data[key] = value
  end
end

#cache(key, &blk) ⇒ Object



178
179
180
181
182
183
# File 'app/models/panda_pal/session.rb', line 178

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



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

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")
    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



236
237
238
239
# File 'app/models/panda_pal/session.rb', line 236

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)


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

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

#canvas_user_idObject



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

def canvas_user_id
  get_lti_cust_param('canvas_user_id')
end

#custom_lti_paramsObject



199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'app/models/panda_pal/session.rb', line 199

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



216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'app/models/panda_pal/session.rb', line 216

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



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

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

#lti_launch_placementObject



195
196
197
# File 'app/models/panda_pal/session.rb', line 195

def lti_launch_placement
  launch_params['https://www.instructure.com/placement'] || launch_params[:launch_type]
end

#lti_platformObject



189
190
191
192
193
# File 'app/models/panda_pal/session.rb', line 189

def lti_platform
  return nil unless data[:lti_platform].present?

  @lti_platform ||= Platform.from_serialized(data[:lti_platform])
end

#lti_rolesObject



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

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

#session_keyObject



137
138
139
140
141
142
143
144
145
146
147
# File 'app/models/panda_pal/session.rb', line 137

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



153
154
155
# File 'app/models/panda_pal/session.rb', line 153

def sign_value(data)
  OpenSSL::HMAC.base64digest(OpenSSL::Digest.new('sha256'), signing_key, data)
end

#signing_keyObject



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

def signing_key
  session_secret
end

#userObject



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

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



157
158
159
160
# File 'app/models/panda_pal/session.rb', line 157

def validate_signature(data, signature)
  # TODO Constant time
  signature == sign_value(data)
end