InstAccess Tokens should prefer shard-local users

closes FOO-2368

TEST PLAN:
  1) have a user-merge occur x-shard
  2) visit an api-gateway mediated pathway
     like account notifications
  3) correct shard-local user should always be loaded
     as auth context

Change-Id: I2dfb86ec35499e9a00ebb8498c4eab9c6c95297e
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/273451
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Rob Orton <rob@instructure.com>
QA-Review: Ethan Vizitei <evizitei@instructure.com>
Product-Review: Ethan Vizitei <evizitei@instructure.com>
This commit is contained in:
Ethan Vizitei 2021-09-13 14:34:47 -05:00
parent 8303761f3f
commit 9aaf1106fc
3 changed files with 176 additions and 24 deletions

View File

@ -42,35 +42,20 @@ module AuthenticationMethods
end
def load_pseudonym_from_inst_access_token(token_string)
return false unless InstAccess::Token.is_token?(token_string)
token = ::AuthenticationMethods::InstAccessToken.parse(token_string)
return false unless token
begin
token = InstAccess::Token.from_token_string(token_string)
rescue InstAccess::InvalidToken, # token didn't pass signature verification
InstAccess::TokenExpired # token passed signature verification, but is expired
raise AccessTokenError
rescue InstAccess::ConfigError => exception
# InstAccess isn't configured. A human should fix that, but this method
# should recover gracefully.
Canvas::Errors.capture_exception(:inst_access, exception, :warn)
return true
end
auth_context = ::AuthenticationMethods::InstAccessToken.load_user_and_pseudonym_context(token, @domain_root_account)
@current_user = User.find_by(uuid: token.user_uuid)
@current_pseudonym = SisPseudonym.for(
@current_user, @domain_root_account, type: :implicit, require_sis: false
)
@current_user = auth_context[:current_user]
@current_pseudonym = auth_context[:current_pseudonym]
raise AccessTokenError unless @current_user && @current_pseudonym
if token.masquerading_user_uuid && token.masquerading_user_shard_id
Shard.lookup(token.masquerading_user_shard_id).activate do
@real_current_user = User.find_by!(uuid: token.masquerading_user_uuid)
@real_current_pseudonym = SisPseudonym.for(
@real_current_user, @domain_root_account, type: :implicit, require_sis: false
)
if auth_context[:real_current_user]
@real_current_user = auth_context[:real_current_user]
@real_current_pseudonym = auth_context[:real_current_pseudonym]
logger.warn "[AUTH] #{@real_current_user.name}(#{@real_current_user.id}) impersonating #{@current_user.name} on page #{request.url}"
end
end
@authenticated_with_jwt = @authenticated_with_inst_access_token = true
end

View File

@ -0,0 +1,96 @@
# frozen_string_literal: true
#
# Copyright (C) 2021 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
module AuthenticationMethods
# this module bridges the gap between the token
# defined in the canvas_security gem and the
# canvas domain itself (users, pseudonyms, accounts, etc)
module InstAccessToken
# given a POTENTIAL token string, this will validate
# it as being an InstAccess token and return
# the token ruby object.
#
# A 'false' indicates this is not a token at all and we can skip
# any other attempts at token processing.
#
# an error (AccessTokenError) means that this IS an inst access token, but
# not a valid one (expired or bad signature) and processing should only
# continue on the assumption that this is an invalid request.
def self.parse(token_string)
return false unless InstAccess::Token.is_token?(token_string)
begin
token = InstAccess::Token.from_token_string(token_string)
return token
rescue InstAccess::InvalidToken, # token didn't pass signature verification
InstAccess::TokenExpired # token passed signature verification, but is expired
raise AccessTokenError
rescue InstAccess::ConfigError => exception
# InstAccess isn't configured. A human should fix that, but this method
# should recover gracefully.
Canvas::Errors.capture_exception(:inst_access, exception, :warn)
return false
end
end
# functionally encapsulates mapping an InstAccess token and a domain root account
# to a user/pseudonym. This is out on it's own because there are some db-state
# edge cases (like multiple users with the same UUID due to user merges, etc)
# that are convenient to test close to the implementation.
#
# the hash this method returns is defined up front with the intention
# that the masquerading keys will only have their values populated if the token contains these values
def self.load_user_and_pseudonym_context(token, domain_root_account)
auth_context = {
current_user: nil,
current_pseudonym: nil,
real_current_user: nil,
real_current_pseudonym: nil
}
auth_context[:current_user] = find_user_by_uuid_prefer_local(token.user_uuid)
auth_context[:current_pseudonym] = SisPseudonym.for(
auth_context[:current_user], domain_root_account, type: :implicit, require_sis: false
)
return auth_context unless auth_context[:current_user] && auth_context[:current_pseudonym]
if token.masquerading_user_uuid && token.masquerading_user_shard_id
Shard.lookup(token.masquerading_user_shard_id).activate do
real_user = find_user_by_uuid_prefer_local(token.masquerading_user_uuid)
raise AccessTokenError, "masquerading user not found" unless real_user
auth_context[:real_current_user] = real_user
auth_context[:real_current_pseudonym] = SisPseudonym.for(
real_user, domain_root_account, type: :implicit, require_sis: false
)
end
end
return auth_context
end
# generally users should not share uuids.
# this is just to make sure that when a shadow
# user or similar exists, the local user
# gets preferred.
def self.find_user_by_uuid_prefer_local(uuid)
User.where(uuid: uuid).order(:id).first
end
private_class_method :find_user_by_uuid_prefer_local
end
end

View File

@ -0,0 +1,71 @@
# frozen_string_literal: true
#
# Copyright (C) 2021 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
require_relative '../../sharding_spec_helper'
describe AuthenticationMethods::InstAccessToken do
let(:signing_keypair) { OpenSSL::PKey::RSA.new(2048) }
let(:encryption_keypair) { OpenSSL::PKey::RSA.new(2048) }
let(:signing_priv_key) { signing_keypair.to_s }
let(:signing_pub_key) { signing_keypair.public_key.to_s }
let(:encryption_priv_key) { encryption_keypair.to_s }
let(:encryption_pub_key) { encryption_keypair.public_key.to_s }
around(:each) do |example|
InstAccess.with_config(signing_key: signing_priv_key) do
example.run
end
end
describe ".parse" do
it "is false for bad tokens" do
result = ::AuthenticationMethods::InstAccessToken.parse("not-a-token")
expect(result).to be_falsey
end
it "returns a token object for good tokens" do
token_obj = ::InstAccess::Token.for_user(user_uuid: 'fake-user-uuid', account_uuid: 'fake-acct-uuid')
result = ::AuthenticationMethods::InstAccessToken.parse(token_obj.to_unencrypted_token_string)
expect(result.user_uuid).to eq('fake-user-uuid')
end
end
describe ".load_user_and_pseudonym_context" do
it "finds the user who created the token" do
account = Account.default
user_with_pseudonym(:active_all => true)
token_obj = ::InstAccess::Token.for_user(user_uuid: @user.uuid, account_uuid: account.uuid)
ctx = ::AuthenticationMethods::InstAccessToken.load_user_and_pseudonym_context(token_obj, account)
expect(ctx[:current_user]).to eq(@user)
expect(ctx[:current_pseudonym]).to eq(@pseudonym)
end
it "chooses the local user when a local and shadow user share the same UUID" do
user_model(id: (10_000_000_000_000 + (rand*10000000).to_i), uuid: 'a-shared-uuid-between-users')
account = Account.default
user_with_pseudonym(:active_all => true)
@user.uuid = 'a-shared-uuid-between-users'
@user.save!
token_obj = ::InstAccess::Token.for_user(user_uuid: 'a-shared-uuid-between-users', account_uuid: account.uuid)
ctx = ::AuthenticationMethods::InstAccessToken.load_user_and_pseudonym_context(token_obj, account)
expect(ctx[:current_user]).to eq(@user)
expect(ctx[:current_pseudonym]).to eq(@pseudonym)
end
end
end