canvas-lms/app/models/authentication_provider.rb

441 lines
13 KiB
Ruby

# frozen_string_literal: true
#
# Copyright (C) 2011 - 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 'net-ldap'
require 'net_ldap_extensions'
class AuthenticationProvider < ActiveRecord::Base
include Workflow
validates :auth_filter, length: {maximum: maximum_text_length, allow_nil: true, allow_blank: true}
workflow do
state :active
state :deleted
end
self.inheritance_column = :auth_type
# backcompat while authentication_providers might be a view
self.primary_key = 'id'
def self.subclass_from_attributes?(_)
false
end
# we have a lot of old data that didn't actually use STI,
# so we shim it
def self.find_sti_class(type_name)
return self if type_name.blank? # super no longer does this in Rails 4
case type_name
when 'cas', 'ldap', 'saml'
const_get(type_name.upcase)
when 'apple', 'clever', 'facebook', 'google', 'microsoft', 'saml_idp_discovery', 'twitter'
const_get(type_name.classify)
when 'canvas'
Canvas
when 'github'
GitHub
when 'linkedin'
LinkedIn
when 'openid_connect'
OpenIDConnect
else
super
end
end
def self.sti_name
display_name.try(:underscore)
end
def self.singleton?
false
end
def self.enabled?(_account = nil)
true
end
def self.supports_debugging?
false
end
def self.debugging_enabled?
::Canvas.redis_enabled?
end
def self.display_name
name.try(:demodulize)
end
def self.login_message
t("Login with %{provider}", provider: display_name)
end
# Drop and recreate the authentication_providers view, if it exists.
#
# to be used from migrations that existed before the table rename. should
# only be used from inside a transaction.
def self.maybe_recreate_view
if (view_exists = connection.view_exists?("authentication_providers"))
connection.execute("DROP VIEW #{connection.quote_table_name('authentication_providers')}")
end
yield
if view_exists
connection.execute("CREATE VIEW #{connection.quote_table_name('authentication_providers')} AS SELECT * FROM #{connection.quote_table_name('account_authorization_configs')}")
end
end
scope :active, ->{ where("workflow_state <> 'deleted'") }
belongs_to :account
include Canvas::RootAccountCacher
has_many :pseudonyms, foreign_key: :authentication_provider_id, inverse_of: :authentication_provider
acts_as_list scope: { account: self, workflow_state: [nil, 'active'] }
def self.valid_auth_types
%w[apple canvas cas clever facebook github google ldap linkedin microsoft openid_connect saml saml_idp_discovery twitter].freeze
end
validates :auth_type,
inclusion: { in: ->(_) { valid_auth_types },
message: -> { "invalid auth_type, must be one of #{valid_auth_types.join(',')}" } }
validates :account_id, presence: true
validate :validate_federated_attributes
# create associate model find to accept auth types, and just return the first one of that
# type
module FindWithType
def find(*args)
if AuthenticationProvider.valid_auth_types.include?(args.first)
where(auth_type: args.first).first!
else
super
end
end
end
def self.recognized_params
[:mfa_required].freeze
end
def self.site_admin_params
[].freeze
end
def self.deprecated_params
[].freeze
end
SENSITIVE_PARAMS = [].freeze
def self.login_button?
Rails.root.join("app/views/shared/svg/_svg_icon_#{sti_name}.svg").exist?
end
def destroy
self.send(:remove_from_list_for_destroy)
self.workflow_state = 'deleted'
self.save!
enable_canvas_authentication
delay_if_production.soft_delete_pseudonyms
true
end
alias destroy_permanently! destroy
def auth_password=(password)
return if password.blank?
self.auth_crypted_password, self.auth_password_salt = ::Canvas::Security.encrypt_password(password, 'instructure_auth')
end
def auth_decrypted_password
return nil unless self.auth_password_salt && self.auth_crypted_password
::Canvas::Security.decrypt_password(self.auth_crypted_password, self.auth_password_salt, 'instructure_auth')
end
def auth_provider_filter
self
end
def self.default_login_handle_name
t(:default_login_handle_name, "Email")
end
def self.default_delegated_login_handle_name
t(:default_delegated_login_handle_name, "Login")
end
def self.serialization_excludes
[:auth_crypted_password, :auth_password_salt]
end
# allowable attributes for federated_attributes setting; nil means anything
# is allowed
def self.recognized_federated_attributes
[].freeze
end
def settings
read_attribute(:settings) || {}
end
def federated_attributes=(value)
value = {} unless value.is_a?(Hash)
settings_will_change! unless value == federated_attributes
settings['federated_attributes'] = value
end
def federated_attributes
settings['federated_attributes'] ||= {}
end
def mfa_required?
return false if account.mfa_settings == :disabled
return true if account.mfa_settings == :required
!!settings['mfa_required']
end
alias mfa_required mfa_required?
def mfa_required=(value)
value = false if account.mfa_settings == :disabled
settings['mfa_required'] = ::Canvas::Plugin.value_to_boolean(value)
end
def federated_attributes_for_api
if jit_provisioning?
federated_attributes
else
result = {}
federated_attributes.each do |(canvas_attribute_name, provider_attribute_config)|
next if provider_attribute_config['provisioning_only']
result[canvas_attribute_name] = provider_attribute_config['attribute']
end
result
end
end
CANVAS_ALLOWED_FEDERATED_ATTRIBUTES = %w{
admin_roles
display_name
email
given_name
integration_id
locale
name
sis_user_id
sortable_name
surname
time_zone
}.freeze
def provision_user(unique_id, provider_attributes = {})
User.transaction(requires_new: true) do
pseudonym = account.pseudonyms.build
pseudonym.user = User.create!(name: unique_id) { |u| u.workflow_state = 'registered' }
pseudonym.authentication_provider = self
pseudonym.unique_id = unique_id
pseudonym.save!
apply_federated_attributes(pseudonym, provider_attributes, purpose: :provisioning)
pseudonym
end
rescue ActiveRecord::RecordNotUnique
self.class.uncached do
pseudonyms.active.by_unique_id(unique_id).take!
end
end
def apply_federated_attributes(pseudonym, provider_attributes, purpose: :login)
user = pseudonym.user
canvas_attributes = translate_provider_attributes(provider_attributes,
purpose: purpose)
given_name = canvas_attributes.delete('given_name')
surname = canvas_attributes.delete('surname')
if given_name || surname
user.name = "#{given_name} #{surname}"
user.sortable_name = if given_name.present? && surname.present?
"#{surname}, #{given_name}"
else
"#{given_name}#{surname}"
end
end
canvas_attributes.each do |(attribute, value)|
# ignore attributes with no value sent; we don't process "deletions" yet
next unless value
case attribute
when 'admin_roles'
role_names = value.is_a?(String) ? value.split(',').map(&:strip) : value
account = pseudonym.account
existing_account_users = account.account_users.merge(user.account_users).preload(:role).to_a
roles = role_names.map do |role_name|
account.get_account_role_by_name(role_name)
end.compact
roles_to_add = roles - existing_account_users.map(&:role)
account_users_to_delete = existing_account_users.select { |au| au.active? && !roles.include?(au.role) }
account_users_to_activate = existing_account_users.select { |au| au.deleted? && roles.include?(au.role) }
roles_to_add.each do |role|
account.account_users.create!(user: user, role: role)
end
account_users_to_delete.each(&:destroy)
account_users_to_activate.each(&:reactivate)
when 'sis_user_id', 'integration_id'
pseudonym[attribute] = value
when 'display_name'
user.short_name = value
when 'email'
cc = user.communication_channels.email.by_path(value).first
cc ||= user.communication_channels.email.new(path: value)
cc.workflow_state = 'active'
cc.save! if cc.changed?
when 'locale'
# convert _ to -, be lenient about case, and perform fallbacks
value = value.tr('_', '-')
lowercase_locales = I18n.available_locales.map(&:to_s).map(&:downcase)
while value.include?('-')
break if lowercase_locales.include?(value.downcase)
value = value.sub(/(?:x-)?-[^-]*$/, '')
end
if (i = lowercase_locales.index(value.downcase))
user.locale = I18n.available_locales[i].to_s
end
else
user.send("#{attribute}=", value)
end
end
if pseudonym.changed?
unless pseudonym.save
Rails.logger.warn("Unable to save federated pseudonym: #{pseudonym.errors}")
end
end
if user.changed?
unless user.save
Rails.logger.warn("Unable to save federated user: #{user.errors}")
end
end
end
def debugging?
return false unless self.class.debugging_enabled?
unless instance_variable_defined?(:@debugging)
@debugging = !!debug_get(:debugging)
end
@debugging
end
def stop_debugging
self.class.debugging_keys.map(&:keys).flatten.each { |key| ::Canvas.redis.del(debug_key(key)) }
end
def start_debugging
stop_debugging # clear old data
debug_set(:debugging, t("Waiting for attempted login"))
@debugging = true
end
def debug_get(key)
::Canvas.redis.get(debug_key(key))
end
def debug_set(key, value, overwrite: true)
::Canvas.redis.set(debug_key(key), value, ex: debug_expire.to_i, nx: overwrite ? nil : true)
end
protected
def statsd_prefix
"auth.account_#{Shard.global_id_for(account_id)}.config_#{self.global_id}"
end
private
def validate_federated_attributes
bad_keys = federated_attributes.keys - CANVAS_ALLOWED_FEDERATED_ATTRIBUTES
unless bad_keys.empty?
errors.add(:federated_attributes, "#{bad_keys.join(', ')} is not an attribute that can be federated")
return
end
# normalize values to { attribute: <attribute>, provisioning_only: true|false }
federated_attributes.each_key do |key|
case federated_attributes[key]
when String
federated_attributes[key] = { 'attribute' => federated_attributes[key], 'provisioning_only' => false }
when Hash
bad_keys = federated_attributes[key].keys - ['attribute', 'provisioning_only']
unless bad_keys.empty?
errors.add(:federated_attributes, "unrecognized key #{bad_keys.join(', ')} in #{key} attribute definition")
return
end
federated_attributes[key]['provisioning_only'] =
::Canvas::Plugin.value_to_boolean(federated_attributes[key]['provisioning_only'])
else
errors.add(:federated_attributes, "invalid attribute definition for #{key}")
return
end
end
return if self.class.recognized_federated_attributes.nil?
bad_values = federated_attributes.values.map { |v| v['attribute'] } - self.class.recognized_federated_attributes
unless bad_values.empty?
errors.add(:federated_attributes, "#{bad_values.join(', ')} is not a valid attribute")
end
end
def translate_provider_attributes(provider_attributes, purpose:)
result = {}
federated_attributes.each do |(canvas_attribute_name, provider_attribute_config)|
next if purpose != :provisioning && provider_attribute_config['provisioning_only']
provider_attribute_name = provider_attribute_config['attribute']
if provider_attributes.key?(provider_attribute_name)
result[canvas_attribute_name] = provider_attributes[provider_attribute_name]
end
end
result
end
def soft_delete_pseudonyms
pseudonyms.find_each(&:destroy)
end
def enable_canvas_authentication
return if account.non_canvas_auth_configured?
account.enable_canvas_authentication
end
def debug_key(key)
['auth_provider_debugging', self.global_id, key.to_s].cache_key
end
def debug_expire
Setting.get('auth_provider_debug_expire_minutes', 30).to_i.minutes
end
end
# so it doesn't get mixed up with ::CAS, ::LinkedIn and ::Twitter
require_dependency 'authentication_provider/canvas'
require_dependency 'authentication_provider/cas'
require_dependency 'authentication_provider/google'
require_dependency 'authentication_provider/linked_in'
require_dependency 'authentication_provider/twitter'