Skip to content
Snippets Groups Projects
saml_authenticator.rb 11.4 KiB
Newer Older
Robin Ward's avatar
Robin Ward committed
# frozen_string_literal: true

class SamlAuthenticator < ::Auth::OAuth2Authenticator
  attr_reader :user, :attributes, :info
  def info=(info)
    @info = info.present? ? info.with_indifferent_access : info
  end

  def initialize(name, opts = {})
    opts[:trusted] ||= true
    super(name, opts)
  end

  def attribute_name_format(type = "basic")
    "urn:oasis:names:tc:SAML:2.0:attrname-format:#{type}"
  end

    # In almost all circumstances, `name` is `saml`.
    # However, some other plugins choose to re-use this Authenticator class
    # with a different `name`. This helper lets them have their own settings,
    # which automatically fall back to the `saml_` defaults.
    ::DiscourseSaml.setting(key, prefer_prefix: "#{name}_")
  def request_attributes
    attrs = "email|name|first_name|last_name"
    custom_attrs = setting(:request_attributes)

    attrs = "#{attrs}|#{custom_attrs}" if custom_attrs.present?

    attrs.split("|").uniq.map do |name|
      { name: name, name_format: attribute_name_format, friendly_name: name }
    statements = "name:fullName,name|email:email,mail|first_name:first_name,firstname,firstName|last_name:last_name,lastname,lastName|nickname:screenName"
    custom_statements = setting(:attribute_statements)
    statements = "#{statements}|#{custom_statements}" if custom_statements.present?

    statements.split("|").map do |statement|
      next if attrs.count != 2
      (result[attrs[0]] ||= []) << attrs[1].split(",")
      result[attrs[0]].flatten!
    result
  end

  def register_middleware(omniauth)
    omniauth.provider ::DiscourseSaml::SamlOmniauthStrategy,
                      setup: lambda { |env|
                        setup_strategy(env["omniauth.strategy"])
                      }
  end

  def setup_strategy(strategy)
    strategy.options.deep_merge!(
      issuer: SamlAuthenticator.saml_base_url,
      idp_sso_target_url: setting(:target_url),
      idp_slo_target_url: setting(:slo_target_url).presence,
      slo_default_relay_state: SamlAuthenticator.saml_base_url,
      idp_cert_fingerprint: setting(:cert_fingerprint).presence,
      idp_cert_fingerprint_algorithm: setting(:cert_fingerprint_algorithm),
      idp_cert: setting(:cert).presence,
      idp_cert_multi: setting(:cert_multi).presence,
      request_attributes: request_attributes,
      attribute_statements: attribute_statements,
      assertion_consumer_service_url: SamlAuthenticator.saml_base_url + "/auth/#{name}/callback",
      single_logout_service_url: SamlAuthenticator.saml_base_url + "/auth/#{name}/slo",
      name_identifier_format: setting(:name_identifier_format).presence,
      request_method: (setting(:request_method)&.downcase == 'post') ? "POST" : "GET",
      certificate: setting(:sp_certificate).presence,
      private_key: setting(:sp_private_key).presence,
        authn_requests_signed: !!setting(:authn_requests_signed),
        want_assertions_signed: !!setting(:want_assertions_signed),
        logout_requests_signed: !!setting(:logout_requests_signed),
        logout_responses_signed: !!setting(:logout_responses_signed),
        signature_method: XMLSecurity::Document::RSA_SHA1
      },
      idp_slo_session_destroy: proc { |env, session| @user.user_auth_tokens.destroy_all; @user.logged_out }
    )
    info[key] || attributes[key]&.join(",") || ""
  def after_authenticate(auth)
    extra_data = auth.extra || {}
    raw_info = extra_data[:raw_info]
    @attributes = raw_info&.attributes || {}

    auth[:uid] = attributes['uid'].try(:first) || auth[:uid] if setting(:use_attributes_uid)
    auth[:info][:email] ||= uid

      ::PluginStore.set("saml", "#{name}_last_auth", auth.inspect)
      ::PluginStore.set("saml", "#{name}_last_auth_raw_info", raw_info.inspect)
      ::PluginStore.set("saml", "#{name}_last_auth_extra", extra_data.inspect)
      data = {
        uid: uid,
        info: info,
        extra: extra_data
      }
      log("#{name}_auth: #{data.inspect}")
    result.username = begin
      if attributes.present?
        username = attributes['screenName'].try(:first)
        username = attributes['uid'].try(:first) if setting(:use_attributes_uid)
      username ||= begin
        source = nil
        source ||= result.name if result.name != uid
        source ||= result.email if result.email != uid
        UserNameSuggester.suggest(source) if source
      end

      fullname = auth.info[:name].presence # From fullName, name, or other custom attribute_statement
      fullname ||= "#{auth.info[:first_name]} #{auth.info[:last_name]}"
    if result.respond_to?(:skip_email_validation) && setting(:skip_email_validation)
      result.skip_email_validation = true
    end

    if setting(:validate_email_fields).present? && attributes['memberOf'].present?
      unless (setting(:validate_email_fields).split("|").map(&:downcase) & attributes['memberOf'].map(&:downcase)).empty?
        result.email_valid = true
      else
        result.email_valid = false
      end
    elsif !setting(:default_emails_valid).nil?
      result.email_valid = setting(:default_emails_valid)
    else
      result.email_valid = true
    end

    result.extra_data[:saml_attributes] = attributes
    result.extra_data[:saml_info] = info
    if result.user.blank?
      result.username = '' if setting(:clear_username)
      result.omit_username = true if setting(:omit_username)
      result.user = auto_create_account(result) if setting(:auto_create_account) && result.email_valid
      @user = result.user
      sync_groups
      sync_custom_fields
    Rails.logger.warn("SAML Debugging: #{info}") if setting(:debug_auth)
  end

  def after_create_account(user, auth)
    self.info = auth[:extra_data][:saml_info]
    @attributes = auth[:extra_data][:saml_attributes]
  def auto_create_account(result)
    email = result.email
    return if User.find_by_email(email).present?

    # Use a mutex here to counter SAML responses that are sent at the same time and the same email payload
    DistributedMutex.synchronize("discourse_saml_#{email}") do
      try_name = result.name.presence
      try_username = result.username.presence

      user_params = {
        primary_email: UserEmail.new(email: email, primary: true),
        name: try_name || User.suggest_name(try_username || email),
        username: UserNameSuggester.suggest(try_username || try_name || email),
        active: true
      }

      user = User.create!(user_params)
      after_create_account(user, result.as_json.with_indifferent_access)

      user
    end
  end

    return unless setting(:sync_groups).present?
    groups_fullsync = setting(:groups_fullsync) || false
    group_attribute = setting(:groups_attribute).presence || 'memberOf'
    user_group_list = (attributes[group_attribute] || []).map(&:downcase)
    if setting(:groups_ldap_leafcn).present?
      # Change cn=groupname,cn=groups,dc=example,dc=com to groupname
      user_group_list = user_group_list.map { |group| group.split(',').first.split('=').last }
    end

    if groups_fullsync
      user_has_groups = user.groups.where(automatic: false).pluck(:name).map(&:downcase)
      groups_to_add = user_group_list - user_has_groups
      if user_has_groups.present?
        groups_to_remove = user_has_groups - user_group_list
      end
    else
      total_group_list = (setting(:sync_groups_list) || "").split('|').map(&:downcase)
      groups_to_add = user_group_list + attr('groups_to_add').split(",").map(&:downcase)
      groups_to_remove = attr('groups_to_remove').split(",").map(&:downcase)
      if total_group_list.present?
        groups_to_add = total_group_list & groups_to_add
        removable_groups = groups_to_remove.dup
        groups_to_remove = total_group_list - groups_to_add
        groups_to_remove &= removable_groups if removable_groups.present?
      end
    return if user_group_list.blank? && groups_to_add.blank? && groups_to_remove.blank?

    Group.where('LOWER(name) IN (?) AND NOT automatic', groups_to_add).each do |group|
    Group.where('LOWER(name) IN (?) AND NOT automatic', groups_to_remove).each do |group|
      group.remove user
    end
  end

    return if user.blank?
    request_attributes.each do |attr|
      key = attr[:name]
      user.custom_fields["#{name}_#{key}"] = attr(key) if attr(key).present?
    statements = setting(:user_field_statements) || ""

    statements.split("|").each do |statement|
      key, field_id = statement.split(":")
      next if key.blank? || field_id.blank?

      user.custom_fields["user_field_#{field_id}"] = attr(key) if attr(key).present?
    end
  end

    email = Email.downcase(email)

    return if user.email == email

    existing_user = User.find_by_email(email)
    if email =~ EmailValidator.email_regex && existing_user.nil?
      user.email = email
      user.save

      user.oauth2_user_infos.where(provider: name, uid: uid).update_all(email: email)
    return unless setting(:sync_moderator)
    is_moderator_attribute = setting(:moderator_attribute) || 'isModerator'
    is_moderator = ['1', 'true'].include?(attributes[is_moderator_attribute].try(:first).to_s.downcase)

    return if user.moderator == is_moderator

    user.moderator = is_moderator
    user.save
  end
    is_admin_attribute = setting(:admin_attribute) || 'isAdmin'
    is_admin = ['1', 'true'].include?(attributes[is_admin_attribute].try(:first).to_s.downcase)

    return if user.admin == is_admin

    user.admin = is_admin
    user.save
  end
    return unless setting(:sync_trust_level)
    trust_level_attribute = setting(:trust_level_attribute) || 'trustLevel'
    level = attributes[trust_level_attribute].try(:first).to_i

Robin Ward's avatar
Robin Ward committed
    return unless level.between?(1, 4)

    if user.manual_locked_trust_level != level
      user.manual_locked_trust_level = level
      user.save
    end

    return if user.trust_level == level

    user.change_trust_level!(level, log_action_for: user)
  end

    return unless setting(:sync_locale)
    locale_attribute = setting(:locale_attribute) || 'locale'
    locale = attributes[locale_attribute].try(:first)

    return unless LocaleSiteSetting.valid_value?(locale)

    if user.locale != locale
      user.locale = locale
      user.save
    end
  end

    # Checking target_url global setting for backwards compatibility
    # (the plugin used to be enabled-by-default)
    setting(:enabled) || !!GlobalSetting.try("#{name}_target_url")
Robin Ward's avatar
Robin Ward committed
  def self.saml_base_url
    DiscourseSaml.setting(:base_url).presence || Discourse.base_url