451 lines
14 KiB
Ruby
451 lines
14 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class DiscourseConnect < DiscourseConnectBase
|
|
class BlankExternalId < StandardError
|
|
end
|
|
|
|
class BannedExternalId < StandardError
|
|
end
|
|
|
|
def self.sso_url
|
|
SiteSetting.discourse_connect_url
|
|
end
|
|
|
|
def self.sso_secret
|
|
SiteSetting.discourse_connect_secret
|
|
end
|
|
|
|
def self.generate_sso(return_path = "/", secure_session:)
|
|
sso = new(secure_session: secure_session)
|
|
sso.nonce = SecureRandom.hex
|
|
sso.register_nonce(return_path)
|
|
sso.return_sso_url = Discourse.base_url + "/session/sso_login"
|
|
sso
|
|
end
|
|
|
|
def self.generate_url(return_path = "/", secure_session:)
|
|
generate_sso(return_path, secure_session: secure_session).to_url
|
|
end
|
|
|
|
def initialize(secure_session:)
|
|
@secure_session = secure_session
|
|
end
|
|
|
|
def register_nonce(return_path)
|
|
if nonce
|
|
if SiteSetting.discourse_connect_csrf_protection
|
|
@secure_session.set(nonce_key, return_path, expires: DiscourseConnectBase.nonce_expiry_time)
|
|
else
|
|
Discourse.cache.write(
|
|
nonce_key,
|
|
return_path,
|
|
expires_in: DiscourseConnectBase.nonce_expiry_time,
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
def nonce_valid?
|
|
if SiteSetting.discourse_connect_csrf_protection
|
|
nonce && @secure_session[nonce_key].present?
|
|
else
|
|
nonce && Discourse.cache.read(nonce_key).present?
|
|
end
|
|
end
|
|
|
|
def nonce_error
|
|
if Discourse.cache.read(used_nonce_key).present?
|
|
"Nonce has already been used"
|
|
elsif SiteSetting.discourse_connect_csrf_protection
|
|
"Nonce is incorrect, was generated in a different browser session, or has expired"
|
|
else
|
|
"Nonce is incorrect, or has expired"
|
|
end
|
|
end
|
|
|
|
def return_path
|
|
if SiteSetting.discourse_connect_csrf_protection
|
|
@secure_session[nonce_key] || "/"
|
|
else
|
|
Discourse.cache.read(nonce_key) || "/"
|
|
end
|
|
end
|
|
|
|
def expire_nonce!
|
|
if nonce
|
|
if SiteSetting.discourse_connect_csrf_protection
|
|
@secure_session[nonce_key] = nil
|
|
else
|
|
Discourse.cache.delete nonce_key
|
|
end
|
|
|
|
Discourse.cache.write(
|
|
used_nonce_key,
|
|
return_path,
|
|
expires_in: DiscourseConnectBase.used_nonce_expiry_time,
|
|
)
|
|
end
|
|
end
|
|
|
|
def nonce_key
|
|
"SSO_NONCE_#{nonce}"
|
|
end
|
|
|
|
def used_nonce_key
|
|
"USED_SSO_NONCE_#{nonce}"
|
|
end
|
|
|
|
BANNED_EXTERNAL_IDS = %w[none nil blank null]
|
|
|
|
def lookup_or_create_user(ip_address = nil)
|
|
# we don't want to ban 0 from being an external id
|
|
external_id = self.external_id.to_s
|
|
|
|
raise BlankExternalId if external_id.blank?
|
|
|
|
raise BannedExternalId, external_id if BANNED_EXTERNAL_IDS.include?(external_id.downcase)
|
|
|
|
# we protect here to ensure there is no situation where the same external id
|
|
# concurrently attempts to create or update sso records
|
|
#
|
|
# we can get duplicate HTTP requests quite easily (client rapid refresh) and this path does stuff such
|
|
# as updating groups for a users and so on that can happen even after the sso record and user is there
|
|
DistributedMutex.synchronize("sso_lookup_or_create_user_#{external_id}") do
|
|
lookup_or_create_user_unsafe(ip_address)
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def lookup_or_create_user_unsafe(ip_address)
|
|
sso_record = SingleSignOnRecord.find_by(external_id: external_id)
|
|
|
|
if sso_record && (user = sso_record.user)
|
|
sso_record.last_payload = unsigned_payload
|
|
else
|
|
user = match_email_or_create_user(ip_address)
|
|
sso_record = user.single_sign_on_record
|
|
end
|
|
|
|
# ensure it's not staged anymore
|
|
user.unstage!
|
|
|
|
change_external_attributes_and_override(sso_record, user)
|
|
|
|
if sso_record && (user = sso_record.user) && !user.active && !require_activation
|
|
user.active = true
|
|
user.save!
|
|
user.enqueue_welcome_message("welcome_user") unless suppress_welcome_message
|
|
user.set_automatic_groups
|
|
end
|
|
|
|
custom_fields.each { |k, v| user.custom_fields[k] = v }
|
|
|
|
user.ip_address = ip_address
|
|
|
|
user.admin = admin unless admin.nil?
|
|
user.moderator = moderator unless moderator.nil?
|
|
|
|
user.title = title unless title.nil?
|
|
|
|
# optionally save the user and sso_record if they have changed
|
|
user.user_avatar.save! if user.user_avatar
|
|
user.save!
|
|
|
|
user.set_automatic_groups if @email_changed && user.active
|
|
|
|
# The user might require approval
|
|
user.create_reviewable
|
|
|
|
if bio && (user.user_profile.bio_raw.blank? || SiteSetting.discourse_connect_overrides_bio)
|
|
user.user_profile.bio_raw = bio
|
|
user.user_profile.save!
|
|
end
|
|
|
|
if website
|
|
user.user_profile.website = website
|
|
user.user_profile.save!
|
|
end
|
|
|
|
if location
|
|
user.user_profile.location = location
|
|
user.user_profile.save!
|
|
end
|
|
|
|
unless admin.nil? && moderator.nil?
|
|
Group.refresh_automatic_groups!(:admins, :moderators, :staff)
|
|
end
|
|
|
|
sso_record.save!
|
|
|
|
apply_group_rules(sso_record.user) if sso_record.user
|
|
|
|
sso_record && sso_record.user
|
|
end
|
|
|
|
def synchronize_groups(user)
|
|
names = (groups || "").split(",").map(&:downcase)
|
|
|
|
current_groups = user.groups.where(automatic: false)
|
|
desired_groups = Group.where("LOWER(NAME) in (?) AND NOT automatic", names)
|
|
|
|
to_be_added = desired_groups
|
|
if current_groups.present?
|
|
to_be_added = to_be_added.where("groups.id NOT IN (?)", current_groups.map(&:id))
|
|
end
|
|
|
|
to_be_removed = current_groups
|
|
if desired_groups.present?
|
|
to_be_removed = to_be_removed.where("groups.id NOT IN (?)", desired_groups.map(&:id))
|
|
end
|
|
|
|
if to_be_added.present? || to_be_removed.present?
|
|
GroupUser.transaction do
|
|
add_user_to_groups(user, to_be_added) if to_be_added.present?
|
|
remove_user_from_groups(user, to_be_removed) if to_be_removed.present?
|
|
end
|
|
end
|
|
end
|
|
|
|
def apply_group_rules(user)
|
|
if SiteSetting.discourse_connect_overrides_groups
|
|
synchronize_groups(user)
|
|
return
|
|
end
|
|
|
|
to_be_added = nil
|
|
if add_groups
|
|
split = add_groups.split(",").map(&:downcase)
|
|
if split.length > 0
|
|
to_be_added = Group.where("LOWER(name) in (?) AND NOT automatic", split)
|
|
if already_member = GroupUser.where(user_id: user.id).pluck(:group_id).presence
|
|
to_be_added = to_be_added.where("id NOT IN (?)", already_member)
|
|
end
|
|
end
|
|
end
|
|
|
|
to_be_removed = nil
|
|
if remove_groups
|
|
split = remove_groups.split(",").map(&:downcase)
|
|
if split.length > 0
|
|
to_be_removed =
|
|
Group
|
|
.joins(:group_users)
|
|
.where(automatic: false, group_users: { user_id: user.id })
|
|
.where("LOWER(name) IN (?)", split)
|
|
end
|
|
end
|
|
|
|
if to_be_added || to_be_removed
|
|
GroupUser.transaction do
|
|
add_user_to_groups(user, to_be_added) if to_be_added
|
|
remove_user_from_groups(user, to_be_removed) if to_be_removed
|
|
end
|
|
end
|
|
end
|
|
|
|
def match_email_or_create_user(ip_address)
|
|
# Use a mutex here to counter SSO requests that are sent at the same time with
|
|
# the same email payload
|
|
DistributedMutex.synchronize("discourse_single_sign_on_#{email}") do
|
|
user = User.find_by_email(email) if !require_activation
|
|
|
|
if !user
|
|
user_params = {
|
|
primary_email: UserEmail.new(email: email, primary: true),
|
|
name: resolve_name,
|
|
username: resolve_username,
|
|
ip_address: ip_address,
|
|
}
|
|
|
|
if SiteSetting.allow_user_locale && locale && LocaleSiteSetting.valid_value?(locale)
|
|
user_params[:locale] = locale
|
|
end
|
|
|
|
user = User.new(user_params)
|
|
|
|
if SiteSetting.must_approve_users && EmailValidator.can_auto_approve_user?(email)
|
|
ReviewableUser.set_approved_fields!(user, Discourse.system_user)
|
|
end
|
|
|
|
begin
|
|
user.save!
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
if SiteSetting.verbose_discourse_connect_logging
|
|
Rails.logger.error(
|
|
"Verbose SSO log: User creation failed. External id: #{external_id}, New User (user_id: #{user.id}) Params: #{user_params} User Params: #{user.attributes} User Errors: #{user.errors.full_messages} Email: #{user.primary_email.attributes} Email Error: #{user.primary_email.errors.full_messages}",
|
|
)
|
|
end
|
|
raise e
|
|
end
|
|
|
|
if SiteSetting.verbose_discourse_connect_logging
|
|
Rails.logger.warn(
|
|
"Verbose SSO log: New User (user_id: #{user.id}) Params: #{user_params} User Params: #{user.attributes} User Errors: #{user.errors.full_messages} Email: #{user.primary_email.attributes} Email Error: #{user.primary_email.errors.full_messages}",
|
|
)
|
|
end
|
|
end
|
|
|
|
if user
|
|
if sso_record = user.single_sign_on_record
|
|
sso_record.last_payload = unsigned_payload
|
|
sso_record.external_id = external_id
|
|
else
|
|
if avatar_url.present?
|
|
Jobs.enqueue(
|
|
:download_avatar_from_url,
|
|
url: avatar_url,
|
|
user_id: user.id,
|
|
override_gravatar: SiteSetting.discourse_connect_overrides_avatar,
|
|
)
|
|
end
|
|
|
|
if profile_background_url.present?
|
|
Jobs.enqueue(
|
|
:download_profile_background_from_url,
|
|
url: profile_background_url,
|
|
user_id: user.id,
|
|
is_card_background: false,
|
|
)
|
|
end
|
|
|
|
if card_background_url.present?
|
|
Jobs.enqueue(
|
|
:download_profile_background_from_url,
|
|
url: card_background_url,
|
|
user_id: user.id,
|
|
is_card_background: true,
|
|
)
|
|
end
|
|
|
|
user.create_single_sign_on_record!(
|
|
last_payload: unsigned_payload,
|
|
external_id: external_id,
|
|
external_username: username,
|
|
external_email: email,
|
|
external_name: name,
|
|
external_avatar_url: avatar_url,
|
|
external_profile_background_url: profile_background_url,
|
|
external_card_background_url: card_background_url,
|
|
)
|
|
end
|
|
end
|
|
|
|
user
|
|
end
|
|
end
|
|
|
|
def change_external_attributes_and_override(sso_record, user)
|
|
@email_changed = false
|
|
|
|
if SiteSetting.auth_overrides_email && email.present? && user.email != Email.downcase(email)
|
|
user.email = email
|
|
user.active = false if require_activation
|
|
@email_changed = true
|
|
end
|
|
|
|
if SiteSetting.auth_overrides_username? && username.present?
|
|
UsernameChanger.override(user, username)
|
|
end
|
|
|
|
if SiteSetting.auth_overrides_name && user.name != name && name.present?
|
|
user.name = name || User.suggest_name(username.blank? ? email : username)
|
|
end
|
|
|
|
if locale_force_update && SiteSetting.allow_user_locale && locale &&
|
|
LocaleSiteSetting.valid_value?(locale)
|
|
user.locale = locale
|
|
end
|
|
|
|
avatar_missing = user.uploaded_avatar_id.nil? || !Upload.exists?(user.uploaded_avatar_id)
|
|
|
|
if (avatar_missing || avatar_force_update || SiteSetting.discourse_connect_overrides_avatar) &&
|
|
avatar_url.present?
|
|
avatar_changed = sso_record.external_avatar_url != avatar_url
|
|
|
|
if avatar_force_update || avatar_changed || avatar_missing
|
|
Jobs.enqueue(
|
|
:download_avatar_from_url,
|
|
url: avatar_url,
|
|
user_id: user.id,
|
|
override_gravatar: SiteSetting.discourse_connect_overrides_avatar,
|
|
)
|
|
end
|
|
end
|
|
|
|
if profile_background_url.present?
|
|
profile_background_missing =
|
|
user.user_profile.profile_background_upload.blank? ||
|
|
Upload.get_from_url(user.user_profile.profile_background_upload.url).blank?
|
|
|
|
if profile_background_missing || SiteSetting.discourse_connect_overrides_profile_background
|
|
profile_background_changed =
|
|
sso_record.external_profile_background_url != profile_background_url
|
|
if profile_background_changed || profile_background_missing
|
|
Jobs.enqueue(
|
|
:download_profile_background_from_url,
|
|
url: profile_background_url,
|
|
user_id: user.id,
|
|
is_card_background: false,
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
if card_background_url.present?
|
|
card_background_missing =
|
|
user.user_profile.card_background_upload.blank? ||
|
|
Upload.get_from_url(user.user_profile.card_background_upload.url).blank?
|
|
if card_background_missing || SiteSetting.discourse_connect_overrides_card_background
|
|
card_background_changed = sso_record.external_card_background_url != card_background_url
|
|
if card_background_changed || card_background_missing
|
|
Jobs.enqueue(
|
|
:download_profile_background_from_url,
|
|
url: card_background_url,
|
|
user_id: user.id,
|
|
is_card_background: true,
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
# change external attributes for sso record
|
|
sso_record.external_username = username
|
|
sso_record.external_email = email
|
|
sso_record.external_name = name
|
|
sso_record.external_avatar_url = avatar_url
|
|
sso_record.external_profile_background_url = profile_background_url
|
|
sso_record.external_card_background_url = card_background_url
|
|
end
|
|
|
|
def resolve_username
|
|
suggester_input = [username]
|
|
suggester_input << name if SiteSetting.use_name_for_username_suggestions
|
|
suggester_input << email if SiteSetting.use_email_for_username_and_name_suggestions
|
|
UserNameSuggester.suggest(*suggester_input)
|
|
end
|
|
|
|
def resolve_name
|
|
name_suggester_input = username.presence
|
|
if SiteSetting.use_email_for_username_and_name_suggestions
|
|
name_suggester_input = name_suggester_input || email
|
|
end
|
|
|
|
name.presence || User.suggest_name(name_suggester_input)
|
|
end
|
|
|
|
def add_user_to_groups(user, groups)
|
|
groups.each do |group|
|
|
GroupUser.create!(user_id: user.id, group_id: group.id)
|
|
GroupActionLogger.new(Discourse.system_user, group).log_add_user_to_group(user)
|
|
end
|
|
end
|
|
|
|
def remove_user_from_groups(user, groups)
|
|
GroupUser.where(user_id: user.id, group_id: groups.map(&:id)).destroy_all
|
|
groups.each do |group|
|
|
GroupActionLogger.new(Discourse.system_user, group).log_remove_user_from_group(user)
|
|
end
|
|
end
|
|
end
|