441 lines
13 KiB
Ruby
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'
|