diff options
author | Horatiu Eugen Vlad <horatiu@vlad.eu> | 2018-02-23 15:10:39 +0300 |
---|---|---|
committer | Horatiu Eugen Vlad <horatiu@vlad.eu> | 2018-02-28 18:53:02 +0300 |
commit | 1ad5df49b1925f1865e99c3fd8576a762aea9cae (patch) | |
tree | b9cee2aabea4c4584883245ada7e8e91e1a01295 /lib/gitlab/auth | |
parent | 77097c9196da7c43d1102249da1d40446176f803 (diff) |
Moved o_auth/saml/ldap modules under gitlab/auth
Diffstat (limited to 'lib/gitlab/auth')
-rw-r--r-- | lib/gitlab/auth/ldap/access.rb | 89 | ||||
-rw-r--r-- | lib/gitlab/auth/ldap/adapter.rb | 110 | ||||
-rw-r--r-- | lib/gitlab/auth/ldap/auth_hash.rb | 48 | ||||
-rw-r--r-- | lib/gitlab/auth/ldap/authentication.rb | 72 | ||||
-rw-r--r-- | lib/gitlab/auth/ldap/config.rb | 237 | ||||
-rw-r--r-- | lib/gitlab/auth/ldap/dn.rb | 303 | ||||
-rw-r--r-- | lib/gitlab/auth/ldap/person.rb | 122 | ||||
-rw-r--r-- | lib/gitlab/auth/ldap/user.rb | 54 | ||||
-rw-r--r-- | lib/gitlab/auth/o_auth/auth_hash.rb | 92 | ||||
-rw-r--r-- | lib/gitlab/auth/o_auth/provider.rb | 56 | ||||
-rw-r--r-- | lib/gitlab/auth/o_auth/session.rb | 21 | ||||
-rw-r--r-- | lib/gitlab/auth/o_auth/user.rb | 246 | ||||
-rw-r--r-- | lib/gitlab/auth/saml/auth_hash.rb | 19 | ||||
-rw-r--r-- | lib/gitlab/auth/saml/config.rb | 21 | ||||
-rw-r--r-- | lib/gitlab/auth/saml/user.rb | 52 |
15 files changed, 1542 insertions, 0 deletions
diff --git a/lib/gitlab/auth/ldap/access.rb b/lib/gitlab/auth/ldap/access.rb new file mode 100644 index 00000000000..77c0ddc2d48 --- /dev/null +++ b/lib/gitlab/auth/ldap/access.rb @@ -0,0 +1,89 @@ +# LDAP authorization model +# +# * Check if we are allowed access (not blocked) +# +module Gitlab + module Auth + module LDAP + class Access + attr_reader :provider, :user + + def self.open(user, &block) + Gitlab::Auth::LDAP::Adapter.open(user.ldap_identity.provider) do |adapter| + block.call(self.new(user, adapter)) + end + end + + def self.allowed?(user) + self.open(user) do |access| + if access.allowed? + Users::UpdateService.new(user, user: user, last_credential_check_at: Time.now).execute + + true + else + false + end + end + end + + def initialize(user, adapter = nil) + @adapter = adapter + @user = user + @provider = user.ldap_identity.provider + end + + def allowed? + if ldap_user + unless ldap_config.active_directory + unblock_user(user, 'is available again') if user.ldap_blocked? + return true + end + + # Block user in GitLab if he/she was blocked in AD + if Gitlab::Auth::LDAP::Person.disabled_via_active_directory?(user.ldap_identity.extern_uid, adapter) + block_user(user, 'is disabled in Active Directory') + false + else + unblock_user(user, 'is not disabled anymore') if user.ldap_blocked? + true + end + else + # Block the user if they no longer exist in LDAP/AD + block_user(user, 'does not exist anymore') + false + end + end + + def adapter + @adapter ||= Gitlab::Auth::LDAP::Adapter.new(provider) + end + + def ldap_config + Gitlab::Auth::LDAP::Config.new(provider) + end + + def ldap_user + @ldap_user ||= Gitlab::Auth::LDAP::Person.find_by_dn(user.ldap_identity.extern_uid, adapter) + end + + def block_user(user, reason) + user.ldap_block + + Gitlab::AppLogger.info( + "LDAP account \"#{user.ldap_identity.extern_uid}\" #{reason}, " \ + "blocking Gitlab user \"#{user.name}\" (#{user.email})" + ) + end + + def unblock_user(user, reason) + user.activate + + Gitlab::AppLogger.info( + "LDAP account \"#{user.ldap_identity.extern_uid}\" #{reason}, " \ + "unblocking Gitlab user \"#{user.name}\" (#{user.email})" + ) + end + end + end + end +end diff --git a/lib/gitlab/auth/ldap/adapter.rb b/lib/gitlab/auth/ldap/adapter.rb new file mode 100644 index 00000000000..caf2d18c668 --- /dev/null +++ b/lib/gitlab/auth/ldap/adapter.rb @@ -0,0 +1,110 @@ +module Gitlab + module Auth + module LDAP + class Adapter + attr_reader :provider, :ldap + + def self.open(provider, &block) + Net::LDAP.open(config(provider).adapter_options) do |ldap| + block.call(self.new(provider, ldap)) + end + end + + def self.config(provider) + Gitlab::Auth::LDAP::Config.new(provider) + end + + def initialize(provider, ldap = nil) + @provider = provider + @ldap = ldap || Net::LDAP.new(config.adapter_options) + end + + def config + Gitlab::Auth::LDAP::Config.new(provider) + end + + def users(fields, value, limit = nil) + options = user_options(Array(fields), value, limit) + + entries = ldap_search(options).select do |entry| + entry.respond_to? config.uid + end + + entries.map do |entry| + Gitlab::Auth::LDAP::Person.new(entry, provider) + end + end + + def user(*args) + users(*args).first + end + + def dn_matches_filter?(dn, filter) + ldap_search(base: dn, + filter: filter, + scope: Net::LDAP::SearchScope_BaseObject, + attributes: %w{dn}).any? + end + + def ldap_search(*args) + # Net::LDAP's `time` argument doesn't work. Use Ruby `Timeout` instead. + Timeout.timeout(config.timeout) do + results = ldap.search(*args) + + if results.nil? + response = ldap.get_operation_result + + unless response.code.zero? + Rails.logger.warn("LDAP search error: #{response.message}") + end + + [] + else + results + end + end + rescue Net::LDAP::Error => error + Rails.logger.warn("LDAP search raised exception #{error.class}: #{error.message}") + [] + rescue Timeout::Error + Rails.logger.warn("LDAP search timed out after #{config.timeout} seconds") + [] + end + + private + + def user_options(fields, value, limit) + options = { + attributes: Gitlab::Auth::LDAP::Person.ldap_attributes(config), + base: config.base + } + + options[:size] = limit if limit + + if fields.include?('dn') + raise ArgumentError, 'It is not currently possible to search the DN and other fields at the same time.' if fields.size > 1 + + options[:base] = value + options[:scope] = Net::LDAP::SearchScope_BaseObject + else + filter = fields.map { |field| Net::LDAP::Filter.eq(field, value) }.inject(:|) + end + + options.merge(filter: user_filter(filter)) + end + + def user_filter(filter = nil) + user_filter = config.constructed_user_filter if config.user_filter.present? + + if user_filter && filter + Net::LDAP::Filter.join(filter, user_filter) + elsif user_filter + user_filter + else + filter + end + end + end + end + end +end diff --git a/lib/gitlab/auth/ldap/auth_hash.rb b/lib/gitlab/auth/ldap/auth_hash.rb new file mode 100644 index 00000000000..ac5c14d374d --- /dev/null +++ b/lib/gitlab/auth/ldap/auth_hash.rb @@ -0,0 +1,48 @@ +# Class to parse and transform the info provided by omniauth +# +module Gitlab + module Auth + module LDAP + class AuthHash < Gitlab::Auth::OAuth::AuthHash + def uid + @uid ||= Gitlab::Auth::LDAP::Person.normalize_dn(super) + end + + def username + super.tap do |username| + username.downcase! if ldap_config.lowercase_usernames + end + end + + private + + def get_info(key) + attributes = ldap_config.attributes[key.to_s] + return super unless attributes + + attributes = Array(attributes) + + value = nil + attributes.each do |attribute| + value = get_raw(attribute) + value = value.first if value + break if value.present? + end + + return super unless value + + Gitlab::Utils.force_utf8(value) + value + end + + def get_raw(key) + auth_hash.extra[:raw_info][key] if auth_hash.extra + end + + def ldap_config + @ldap_config ||= Gitlab::Auth::LDAP::Config.new(self.provider) + end + end + end + end +end diff --git a/lib/gitlab/auth/ldap/authentication.rb b/lib/gitlab/auth/ldap/authentication.rb new file mode 100644 index 00000000000..cbb9cf4bb9c --- /dev/null +++ b/lib/gitlab/auth/ldap/authentication.rb @@ -0,0 +1,72 @@ +# These calls help to authenticate to LDAP by providing username and password +# +# Since multiple LDAP servers are supported, it will loop through all of them +# until a valid bind is found +# + +module Gitlab + module Auth + module LDAP + class Authentication + def self.login(login, password) + return unless Gitlab::Auth::LDAP::Config.enabled? + return unless login.present? && password.present? + + auth = nil + # loop through providers until valid bind + providers.find do |provider| + auth = new(provider) + auth.login(login, password) # true will exit the loop + end + + # If (login, password) was invalid for all providers, the value of auth is now the last + # Gitlab::Auth::LDAP::Authentication instance we tried. + auth.user + end + + def self.providers + Gitlab::Auth::LDAP::Config.providers + end + + attr_accessor :provider, :ldap_user + + def initialize(provider) + @provider = provider + end + + def login(login, password) + @ldap_user = adapter.bind_as( + filter: user_filter(login), + size: 1, + password: password + ) + end + + def adapter + OmniAuth::LDAP::Adaptor.new(config.omniauth_options) + end + + def config + Gitlab::Auth::LDAP::Config.new(provider) + end + + def user_filter(login) + filter = Net::LDAP::Filter.equals(config.uid, login) + + # Apply LDAP user filter if present + if config.user_filter.present? + filter = Net::LDAP::Filter.join(filter, config.constructed_user_filter) + end + + filter + end + + def user + return nil unless ldap_user + + Gitlab::Auth::LDAP::User.find_by_uid_and_provider(ldap_user.dn, provider) + end + end + end + end +end diff --git a/lib/gitlab/auth/ldap/config.rb b/lib/gitlab/auth/ldap/config.rb new file mode 100644 index 00000000000..77185f52ced --- /dev/null +++ b/lib/gitlab/auth/ldap/config.rb @@ -0,0 +1,237 @@ +# Load a specific server configuration +module Gitlab + module Auth + module LDAP + class Config + NET_LDAP_ENCRYPTION_METHOD = { + simple_tls: :simple_tls, + start_tls: :start_tls, + plain: nil + }.freeze + + attr_accessor :provider, :options + + def self.enabled? + Gitlab.config.ldap.enabled + end + + def self.servers + Gitlab.config.ldap['servers']&.values || [] + end + + def self.available_servers + return [] unless enabled? + + Array.wrap(servers.first) + end + + def self.providers + servers.map { |server| server['provider_name'] } + end + + def self.valid_provider?(provider) + providers.include?(provider) + end + + def self.invalid_provider(provider) + raise "Unknown provider (#{provider}). Available providers: #{providers}" + end + + def initialize(provider) + if self.class.valid_provider?(provider) + @provider = provider + else + self.class.invalid_provider(provider) + end + + @options = config_for(@provider) # Use @provider, not provider + end + + def enabled? + base_config.enabled + end + + def adapter_options + opts = base_options.merge( + encryption: encryption_options + ) + + opts.merge!(auth_options) if has_auth? + + opts + end + + def omniauth_options + opts = base_options.merge( + base: base, + encryption: options['encryption'], + filter: omniauth_user_filter, + name_proc: name_proc, + disable_verify_certificates: !options['verify_certificates'] + ) + + if has_auth? + opts.merge!( + bind_dn: options['bind_dn'], + password: options['password'] + ) + end + + opts[:ca_file] = options['ca_file'] if options['ca_file'].present? + opts[:ssl_version] = options['ssl_version'] if options['ssl_version'].present? + + opts + end + + def base + options['base'] + end + + def uid + options['uid'] + end + + def sync_ssh_keys? + sync_ssh_keys.present? + end + + # The LDAP attribute in which the ssh keys are stored + def sync_ssh_keys + options['sync_ssh_keys'] + end + + def user_filter + options['user_filter'] + end + + def constructed_user_filter + @constructed_user_filter ||= Net::LDAP::Filter.construct(user_filter) + end + + def group_base + options['group_base'] + end + + def admin_group + options['admin_group'] + end + + def active_directory + options['active_directory'] + end + + def block_auto_created_users + options['block_auto_created_users'] + end + + def attributes + default_attributes.merge(options['attributes']) + end + + def timeout + options['timeout'].to_i + end + + def has_auth? + options['password'] || options['bind_dn'] + end + + def allow_username_or_email_login + options['allow_username_or_email_login'] + end + + def lowercase_usernames + options['lowercase_usernames'] + end + + def name_proc + if allow_username_or_email_login + proc { |name| name.gsub(/@.*\z/, '') } + else + proc { |name| name } + end + end + + def default_attributes + { + 'username' => %w(uid sAMAccountName userid), + 'email' => %w(mail email userPrincipalName), + 'name' => 'cn', + 'first_name' => 'givenName', + 'last_name' => 'sn' + } + end + + protected + + def base_options + { + host: options['host'], + port: options['port'] + } + end + + def base_config + Gitlab.config.ldap + end + + def config_for(provider) + base_config.servers.values.find { |server| server['provider_name'] == provider } + end + + def encryption_options + method = translate_method(options['encryption']) + return nil unless method + + { + method: method, + tls_options: tls_options(method) + } + end + + def translate_method(method_from_config) + NET_LDAP_ENCRYPTION_METHOD[method_from_config.to_sym] + end + + def tls_options(method) + return { verify_mode: OpenSSL::SSL::VERIFY_NONE } unless method + + opts = if options['verify_certificates'] + OpenSSL::SSL::SSLContext::DEFAULT_PARAMS + else + # It is important to explicitly set verify_mode for two reasons: + # 1. The behavior of OpenSSL is undefined when verify_mode is not set. + # 2. The net-ldap gem implementation verifies the certificate hostname + # unless verify_mode is set to VERIFY_NONE. + { verify_mode: OpenSSL::SSL::VERIFY_NONE } + end + + opts[:ca_file] = options['ca_file'] if options['ca_file'].present? + opts[:ssl_version] = options['ssl_version'] if options['ssl_version'].present? + + opts + end + + def auth_options + { + auth: { + method: :simple, + username: options['bind_dn'], + password: options['password'] + } + } + end + + def omniauth_user_filter + uid_filter = Net::LDAP::Filter.eq(uid, '%{username}') + + if user_filter.present? + Net::LDAP::Filter.join(uid_filter, constructed_user_filter).to_s + else + uid_filter.to_s + end + end + end + end + end +end diff --git a/lib/gitlab/auth/ldap/dn.rb b/lib/gitlab/auth/ldap/dn.rb new file mode 100644 index 00000000000..1fa5338f5a6 --- /dev/null +++ b/lib/gitlab/auth/ldap/dn.rb @@ -0,0 +1,303 @@ +# -*- ruby encoding: utf-8 -*- + +# Based on the `ruby-net-ldap` gem's `Net::LDAP::DN` +# +# For our purposes, this class is used to normalize DNs in order to allow proper +# comparison. +# +# E.g. DNs should be compared case-insensitively (in basically all LDAP +# implementations or setups), therefore we downcase every DN. + +## +# Objects of this class represent an LDAP DN ("Distinguished Name"). A DN +# ("Distinguished Name") is a unique identifier for an entry within an LDAP +# directory. It is made up of a number of other attributes strung together, +# to identify the entry in the tree. +# +# Each attribute that makes up a DN needs to have its value escaped so that +# the DN is valid. This class helps take care of that. +# +# A fully escaped DN needs to be unescaped when analysing its contents. This +# class also helps take care of that. +module Gitlab + module Auth + module LDAP + class DN + FormatError = Class.new(StandardError) + MalformedError = Class.new(FormatError) + UnsupportedError = Class.new(FormatError) + + def self.normalize_value(given_value) + dummy_dn = "placeholder=#{given_value}" + normalized_dn = new(*dummy_dn).to_normalized_s + normalized_dn.sub(/\Aplaceholder=/, '') + end + + ## + # Initialize a DN, escaping as required. Pass in attributes in name/value + # pairs. If there is a left over argument, it will be appended to the dn + # without escaping (useful for a base string). + # + # Most uses of this class will be to escape a DN, rather than to parse it, + # so storing the dn as an escaped String and parsing parts as required + # with a state machine seems sensible. + def initialize(*args) + if args.length > 1 + initialize_array(args) + else + initialize_string(args[0]) + end + end + + ## + # Parse a DN into key value pairs using ASN from + # http://tools.ietf.org/html/rfc2253 section 3. + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/PerceivedComplexity + def each_pair + state = :key + key = StringIO.new + value = StringIO.new + hex_buffer = "" + + @dn.each_char.with_index do |char, dn_index| + case state + when :key then + case char + when 'a'..'z', 'A'..'Z' then + state = :key_normal + key << char + when '0'..'9' then + state = :key_oid + key << char + when ' ' then state = :key + else raise(MalformedError, "Unrecognized first character of an RDN attribute type name \"#{char}\"") + end + when :key_normal then + case char + when '=' then state = :value + when 'a'..'z', 'A'..'Z', '0'..'9', '-', ' ' then key << char + else raise(MalformedError, "Unrecognized RDN attribute type name character \"#{char}\"") + end + when :key_oid then + case char + when '=' then state = :value + when '0'..'9', '.', ' ' then key << char + else raise(MalformedError, "Unrecognized RDN OID attribute type name character \"#{char}\"") + end + when :value then + case char + when '\\' then state = :value_normal_escape + when '"' then state = :value_quoted + when ' ' then state = :value + when '#' then + state = :value_hexstring + value << char + when ',' then + state = :key + yield key.string.strip, rstrip_except_escaped(value.string, dn_index) + key = StringIO.new + value = StringIO.new + else + state = :value_normal + value << char + end + when :value_normal then + case char + when '\\' then state = :value_normal_escape + when ',' then + state = :key + yield key.string.strip, rstrip_except_escaped(value.string, dn_index) + key = StringIO.new + value = StringIO.new + when '+' then raise(UnsupportedError, "Multivalued RDNs are not supported") + else value << char + end + when :value_normal_escape then + case char + when '0'..'9', 'a'..'f', 'A'..'F' then + state = :value_normal_escape_hex + hex_buffer = char + else + state = :value_normal + value << char + end + when :value_normal_escape_hex then + case char + when '0'..'9', 'a'..'f', 'A'..'F' then + state = :value_normal + value << "#{hex_buffer}#{char}".to_i(16).chr + else raise(MalformedError, "Invalid escaped hex code \"\\#{hex_buffer}#{char}\"") + end + when :value_quoted then + case char + when '\\' then state = :value_quoted_escape + when '"' then state = :value_end + else value << char + end + when :value_quoted_escape then + case char + when '0'..'9', 'a'..'f', 'A'..'F' then + state = :value_quoted_escape_hex + hex_buffer = char + else + state = :value_quoted + value << char + end + when :value_quoted_escape_hex then + case char + when '0'..'9', 'a'..'f', 'A'..'F' then + state = :value_quoted + value << "#{hex_buffer}#{char}".to_i(16).chr + else raise(MalformedError, "Expected the second character of a hex pair inside a double quoted value, but got \"#{char}\"") + end + when :value_hexstring then + case char + when '0'..'9', 'a'..'f', 'A'..'F' then + state = :value_hexstring_hex + value << char + when ' ' then state = :value_end + when ',' then + state = :key + yield key.string.strip, rstrip_except_escaped(value.string, dn_index) + key = StringIO.new + value = StringIO.new + else raise(MalformedError, "Expected the first character of a hex pair, but got \"#{char}\"") + end + when :value_hexstring_hex then + case char + when '0'..'9', 'a'..'f', 'A'..'F' then + state = :value_hexstring + value << char + else raise(MalformedError, "Expected the second character of a hex pair, but got \"#{char}\"") + end + when :value_end then + case char + when ' ' then state = :value_end + when ',' then + state = :key + yield key.string.strip, rstrip_except_escaped(value.string, dn_index) + key = StringIO.new + value = StringIO.new + else raise(MalformedError, "Expected the end of an attribute value, but got \"#{char}\"") + end + else raise "Fell out of state machine" + end + end + + # Last pair + raise(MalformedError, 'DN string ended unexpectedly') unless + [:value, :value_normal, :value_hexstring, :value_end].include? state + + yield key.string.strip, rstrip_except_escaped(value.string, @dn.length) + end + + def rstrip_except_escaped(str, dn_index) + str_ends_with_whitespace = str.match(/\s\z/) + + if str_ends_with_whitespace + dn_part_ends_with_escaped_whitespace = @dn[0, dn_index].match(/\\(\s+)\z/) + + if dn_part_ends_with_escaped_whitespace + dn_part_rwhitespace = dn_part_ends_with_escaped_whitespace[1] + num_chars_to_remove = dn_part_rwhitespace.length - 1 + str = str[0, str.length - num_chars_to_remove] + else + str.rstrip! + end + end + + str + end + + ## + # Returns the DN as an array in the form expected by the constructor. + def to_a + a = [] + self.each_pair { |key, value| a << key << value } unless @dn.empty? + a + end + + ## + # Return the DN as an escaped string. + def to_s + @dn + end + + ## + # Return the DN as an escaped and normalized string. + def to_normalized_s + self.class.new(*to_a).to_s.downcase + end + + # https://tools.ietf.org/html/rfc4514 section 2.4 lists these exceptions + # for DN values. All of the following must be escaped in any normal string + # using a single backslash ('\') as escape. The space character is left + # out here because in a "normalized" string, spaces should only be escaped + # if necessary (i.e. leading or trailing space). + NORMAL_ESCAPES = [',', '+', '"', '\\', '<', '>', ';', '='].freeze + + # The following must be represented as escaped hex + HEX_ESCAPES = { + "\n" => '\0a', + "\r" => '\0d' + }.freeze + + # Compiled character class regexp using the keys from the above hash, and + # checking for a space or # at the start, or space at the end, of the + # string. + ESCAPE_RE = Regexp.new("(^ |^#| $|[" + + NORMAL_ESCAPES.map { |e| Regexp.escape(e) }.join + + "])") + + HEX_ESCAPE_RE = Regexp.new("([" + + HEX_ESCAPES.keys.map { |e| Regexp.escape(e) }.join + + "])") + + ## + # Escape a string for use in a DN value + def self.escape(string) + escaped = string.gsub(ESCAPE_RE) { |char| "\\" + char } + escaped.gsub(HEX_ESCAPE_RE) { |char| HEX_ESCAPES[char] } + end + + private + + def initialize_array(args) + buffer = StringIO.new + + args.each_with_index do |arg, index| + if index.even? # key + buffer << "," if index > 0 + buffer << arg + else # value + buffer << "=" + buffer << self.class.escape(arg) + end + end + + @dn = buffer.string + end + + def initialize_string(arg) + @dn = arg.to_s + end + + ## + # Proxy all other requests to the string object, because a DN is mainly + # used within the library as a string + # rubocop:disable GitlabSecurity/PublicSend + def method_missing(method, *args, &block) + @dn.send(method, *args, &block) + end + + ## + # Redefined to be consistent with redefined `method_missing` behavior + def respond_to?(sym, include_private = false) + @dn.respond_to?(sym, include_private) + end + end + end + end +end diff --git a/lib/gitlab/auth/ldap/person.rb b/lib/gitlab/auth/ldap/person.rb new file mode 100644 index 00000000000..8dfae3ee541 --- /dev/null +++ b/lib/gitlab/auth/ldap/person.rb @@ -0,0 +1,122 @@ +module Gitlab + module Auth + module LDAP + class Person + # Active Directory-specific LDAP filter that checks if bit 2 of the + # userAccountControl attribute is set. + # Source: http://ctogonewild.com/2009/09/03/bitmask-searches-in-ldap/ + AD_USER_DISABLED = Net::LDAP::Filter.ex("userAccountControl:1.2.840.113556.1.4.803", "2") + + InvalidEntryError = Class.new(StandardError) + + attr_accessor :entry, :provider + + def self.find_by_uid(uid, adapter) + uid = Net::LDAP::Filter.escape(uid) + adapter.user(adapter.config.uid, uid) + end + + def self.find_by_dn(dn, adapter) + adapter.user('dn', dn) + end + + def self.find_by_email(email, adapter) + email_fields = adapter.config.attributes['email'] + + adapter.user(email_fields, email) + end + + def self.disabled_via_active_directory?(dn, adapter) + adapter.dn_matches_filter?(dn, AD_USER_DISABLED) + end + + def self.ldap_attributes(config) + [ + 'dn', + config.uid, + *config.attributes['name'], + *config.attributes['email'], + *config.attributes['username'] + ].compact.uniq + end + + def self.normalize_dn(dn) + ::Gitlab::Auth::LDAP::DN.new(dn).to_normalized_s + rescue ::Gitlab::Auth::LDAP::DN::FormatError => e + Rails.logger.info("Returning original DN \"#{dn}\" due to error during normalization attempt: #{e.message}") + + dn + end + + # Returns the UID in a normalized form. + # + # 1. Excess spaces are stripped + # 2. The string is downcased (for case-insensitivity) + def self.normalize_uid(uid) + ::Gitlab::Auth::LDAP::DN.normalize_value(uid) + rescue ::Gitlab::Auth::LDAP::DN::FormatError => e + Rails.logger.info("Returning original UID \"#{uid}\" due to error during normalization attempt: #{e.message}") + + uid + end + + def initialize(entry, provider) + Rails.logger.debug { "Instantiating #{self.class.name} with LDIF:\n#{entry.to_ldif}" } + @entry = entry + @provider = provider + end + + def name + attribute_value(:name).first + end + + def uid + entry.public_send(config.uid).first # rubocop:disable GitlabSecurity/PublicSend + end + + def username + username = attribute_value(:username) + + # Depending on the attribute, multiple values may + # be returned. We need only one for username. + # Ex. `uid` returns only one value but `mail` may + # return an array of multiple email addresses. + [username].flatten.first.tap do |username| + username.downcase! if config.lowercase_usernames + end + end + + def email + attribute_value(:email) + end + + def dn + self.class.normalize_dn(entry.dn) + end + + private + + def entry + @entry + end + + def config + @config ||= Gitlab::Auth::LDAP::Config.new(provider) + end + + # Using the LDAP attributes configuration, find and return the first + # attribute with a value. For example, by default, when given 'email', + # this method looks for 'mail', 'email' and 'userPrincipalName' and + # returns the first with a value. + def attribute_value(attribute) + attributes = Array(config.attributes[attribute.to_s]) + selected_attr = attributes.find { |attr| entry.respond_to?(attr) } + + return nil unless selected_attr + + entry.public_send(selected_attr) # rubocop:disable GitlabSecurity/PublicSend + end + end + end + end +end diff --git a/lib/gitlab/auth/ldap/user.rb b/lib/gitlab/auth/ldap/user.rb new file mode 100644 index 00000000000..068212d9a21 --- /dev/null +++ b/lib/gitlab/auth/ldap/user.rb @@ -0,0 +1,54 @@ +# LDAP extension for User model +# +# * Find or create user from omniauth.auth data +# * Links LDAP account with existing user +# * Auth LDAP user with login and password +# +module Gitlab + module Auth + module LDAP + class User < Gitlab::Auth::OAuth::User + class << self + def find_by_uid_and_provider(uid, provider) + identity = ::Identity.with_extern_uid(provider, uid).take + + identity && identity.user + end + end + + def save + super('LDAP') + end + + # instance methods + def find_user + find_by_uid_and_provider || find_by_email || build_new_user + end + + def find_by_uid_and_provider + self.class.find_by_uid_and_provider(auth_hash.uid, auth_hash.provider) + end + + def changed? + gl_user.changed? || gl_user.identities.any?(&:changed?) + end + + def block_after_signup? + ldap_config.block_auto_created_users + end + + def allowed? + Gitlab::Auth::LDAP::Access.allowed?(gl_user) + end + + def ldap_config + Gitlab::Auth::LDAP::Config.new(auth_hash.provider) + end + + def auth_hash=(auth_hash) + @auth_hash = Gitlab::Auth::LDAP::AuthHash.new(auth_hash) + end + end + end + end +end diff --git a/lib/gitlab/auth/o_auth/auth_hash.rb b/lib/gitlab/auth/o_auth/auth_hash.rb new file mode 100644 index 00000000000..ed8fba94305 --- /dev/null +++ b/lib/gitlab/auth/o_auth/auth_hash.rb @@ -0,0 +1,92 @@ +# Class to parse and transform the info provided by omniauth +# +module Gitlab + module Auth + module OAuth + class AuthHash + attr_reader :auth_hash + def initialize(auth_hash) + @auth_hash = auth_hash + end + + def uid + @uid ||= Gitlab::Utils.force_utf8(auth_hash.uid.to_s) + end + + def provider + @provider ||= auth_hash.provider.to_s + end + + def name + @name ||= get_info(:name) || "#{get_info(:first_name)} #{get_info(:last_name)}" + end + + def username + @username ||= username_and_email[:username].to_s + end + + def email + @email ||= username_and_email[:email].to_s + end + + def password + @password ||= Gitlab::Utils.force_utf8(Devise.friendly_token[0, 8].downcase) + end + + def location + location = get_info(:address) + if location.is_a?(Hash) + [location.locality.presence, location.country.presence].compact.join(', ') + else + location + end + end + + def has_attribute?(attribute) + if attribute == :location + get_info(:address).present? + else + get_info(attribute).present? + end + end + + private + + def info + auth_hash.info + end + + def get_info(key) + value = info[key] + Gitlab::Utils.force_utf8(value) if value + value + end + + def username_and_email + @username_and_email ||= begin + username = get_info(:username).presence || get_info(:nickname).presence + email = get_info(:email).presence + + username ||= generate_username(email) if email + email ||= generate_temporarily_email(username) if username + + { + username: username, + email: email + } + end + end + + # Get the first part of the email address (before @) + # In addtion in removes illegal characters + def generate_username(email) + email.match(/^[^@]*/)[0].mb_chars.normalize(:kd).gsub(/[^\x00-\x7F]/, '').to_s + end + + def generate_temporarily_email(username) + "temp-email-for-oauth-#{username}@gitlab.localhost" + end + end + end + end +end diff --git a/lib/gitlab/auth/o_auth/provider.rb b/lib/gitlab/auth/o_auth/provider.rb new file mode 100644 index 00000000000..f8ab8ee1388 --- /dev/null +++ b/lib/gitlab/auth/o_auth/provider.rb @@ -0,0 +1,56 @@ +module Gitlab + module Auth + module OAuth + class Provider + LABELS = { + "github" => "GitHub", + "gitlab" => "GitLab.com", + "google_oauth2" => "Google" + }.freeze + + def self.providers + Devise.omniauth_providers + end + + def self.enabled?(name) + providers.include?(name.to_sym) + end + + def self.ldap_provider?(name) + name.to_s.start_with?('ldap') + end + + def self.sync_profile_from_provider?(provider) + return true if ldap_provider?(provider) + + providers = Gitlab.config.omniauth.sync_profile_from_provider + + if providers.is_a?(Array) + providers.include?(provider) + else + providers + end + end + + def self.config_for(name) + name = name.to_s + if ldap_provider?(name) + if Gitlab::Auth::LDAP::Config.valid_provider?(name) + Gitlab::Auth::LDAP::Config.new(name).options + else + nil + end + else + Gitlab.config.omniauth.providers.find { |provider| provider.name == name } + end + end + + def self.label_for(name) + name = name.to_s + config = config_for(name) + (config && config['label']) || LABELS[name] || name.titleize + end + end + end + end +end diff --git a/lib/gitlab/auth/o_auth/session.rb b/lib/gitlab/auth/o_auth/session.rb new file mode 100644 index 00000000000..8f2b4d58552 --- /dev/null +++ b/lib/gitlab/auth/o_auth/session.rb @@ -0,0 +1,21 @@ +# :nocov: +module Gitlab + module Auth + module OAuth + module Session + def self.create(provider, ticket) + Rails.cache.write("gitlab:#{provider}:#{ticket}", ticket, expires_in: Gitlab.config.omniauth.cas3.session_duration) + end + + def self.destroy(provider, ticket) + Rails.cache.delete("gitlab:#{provider}:#{ticket}") + end + + def self.valid?(provider, ticket) + Rails.cache.read("gitlab:#{provider}:#{ticket}").present? + end + end + end + end +end +# :nocov: diff --git a/lib/gitlab/auth/o_auth/user.rb b/lib/gitlab/auth/o_auth/user.rb new file mode 100644 index 00000000000..acd785bb02d --- /dev/null +++ b/lib/gitlab/auth/o_auth/user.rb @@ -0,0 +1,246 @@ +# OAuth extension for User model +# +# * Find GitLab user based on omniauth uid and provider +# * Create new user from omniauth data +# +module Gitlab + module Auth + module OAuth + class User + SignupDisabledError = Class.new(StandardError) + SigninDisabledForProviderError = Class.new(StandardError) + + attr_accessor :auth_hash, :gl_user + + def initialize(auth_hash) + self.auth_hash = auth_hash + update_profile + add_or_update_user_identities + end + + def persisted? + gl_user.try(:persisted?) + end + + def new? + !persisted? + end + + def valid? + gl_user.try(:valid?) + end + + def save(provider = 'OAuth') + raise SigninDisabledForProviderError if oauth_provider_disabled? + raise SignupDisabledError unless gl_user + + block_after_save = needs_blocking? + + Users::UpdateService.new(gl_user, user: gl_user).execute! + + gl_user.block if block_after_save + + log.info "(#{provider}) saving user #{auth_hash.email} from login with extern_uid => #{auth_hash.uid}" + gl_user + rescue ActiveRecord::RecordInvalid => e + log.info "(#{provider}) Error saving user #{auth_hash.uid} (#{auth_hash.email}): #{gl_user.errors.full_messages}" + return self, e.record.errors + end + + def gl_user + return @gl_user if defined?(@gl_user) + + @gl_user = find_user + end + + def find_user + user = find_by_uid_and_provider + + user ||= find_or_build_ldap_user if auto_link_ldap_user? + user ||= build_new_user if signup_enabled? + + user.external = true if external_provider? && user&.new_record? + + user + end + + protected + + def add_or_update_user_identities + return unless gl_user + + # find_or_initialize_by doesn't update `gl_user.identities`, and isn't autosaved. + identity = gl_user.identities.find { |identity| identity.provider == auth_hash.provider } + + identity ||= gl_user.identities.build(provider: auth_hash.provider) + identity.extern_uid = auth_hash.uid + + if auto_link_ldap_user? && !gl_user.ldap_user? && ldap_person + log.info "Correct LDAP account has been found. identity to user: #{gl_user.username}." + gl_user.identities.build(provider: ldap_person.provider, extern_uid: ldap_person.dn) + end + end + + def find_or_build_ldap_user + return unless ldap_person + + user = Gitlab::Auth::LDAP::User.find_by_uid_and_provider(ldap_person.dn, ldap_person.provider) + if user + log.info "LDAP account found for user #{user.username}. Building new #{auth_hash.provider} identity." + return user + end + + log.info "No user found using #{auth_hash.provider} provider. Creating a new one." + build_new_user + end + + def find_by_email + return unless auth_hash.has_attribute?(:email) + + ::User.find_by(email: auth_hash.email.downcase) + end + + def auto_link_ldap_user? + Gitlab.config.omniauth.auto_link_ldap_user + end + + def creating_linked_ldap_user? + auto_link_ldap_user? && ldap_person + end + + def ldap_person + return @ldap_person if defined?(@ldap_person) + + # Look for a corresponding person with same uid in any of the configured LDAP providers + Gitlab::Auth::LDAP::Config.providers.each do |provider| + adapter = Gitlab::Auth::LDAP::Adapter.new(provider) + @ldap_person = find_ldap_person(auth_hash, adapter) + break if @ldap_person + end + @ldap_person + end + + def find_ldap_person(auth_hash, adapter) + Gitlab::Auth::LDAP::Person.find_by_uid(auth_hash.uid, adapter) || + Gitlab::Auth::LDAP::Person.find_by_email(auth_hash.uid, adapter) || + Gitlab::Auth::LDAP::Person.find_by_dn(auth_hash.uid, adapter) + end + + def ldap_config + Gitlab::Auth::LDAP::Config.new(ldap_person.provider) if ldap_person + end + + def needs_blocking? + new? && block_after_signup? + end + + def signup_enabled? + providers = Gitlab.config.omniauth.allow_single_sign_on + if providers.is_a?(Array) + providers.include?(auth_hash.provider) + else + providers + end + end + + def external_provider? + Gitlab.config.omniauth.external_providers.include?(auth_hash.provider) + end + + def block_after_signup? + if creating_linked_ldap_user? + ldap_config.block_auto_created_users + else + Gitlab.config.omniauth.block_auto_created_users + end + end + + def auth_hash=(auth_hash) + @auth_hash = AuthHash.new(auth_hash) + end + + def find_by_uid_and_provider + identity = Identity.with_extern_uid(auth_hash.provider, auth_hash.uid).take + identity && identity.user + end + + def build_new_user + user_params = user_attributes.merge(skip_confirmation: true) + Users::BuildService.new(nil, user_params).execute(skip_authorization: true) + end + + def user_attributes + # Give preference to LDAP for sensitive information when creating a linked account + if creating_linked_ldap_user? + username = ldap_person.username.presence + email = ldap_person.email.first.presence + end + + username ||= auth_hash.username + email ||= auth_hash.email + + valid_username = ::Namespace.clean_path(username) + + uniquify = Uniquify.new + valid_username = uniquify.string(valid_username) { |s| !NamespacePathValidator.valid_path?(s) } + + name = auth_hash.name + name = valid_username if name.strip.empty? + + { + name: name, + username: valid_username, + email: email, + password: auth_hash.password, + password_confirmation: auth_hash.password, + password_automatically_set: true + } + end + + def sync_profile_from_provider? + Gitlab::Auth::OAuth::Provider.sync_profile_from_provider?(auth_hash.provider) + end + + def update_profile + clear_user_synced_attributes_metadata + + return unless sync_profile_from_provider? || creating_linked_ldap_user? + + metadata = gl_user.build_user_synced_attributes_metadata + + if sync_profile_from_provider? + UserSyncedAttributesMetadata::SYNCABLE_ATTRIBUTES.each do |key| + if auth_hash.has_attribute?(key) && gl_user.sync_attribute?(key) + gl_user[key] = auth_hash.public_send(key) # rubocop:disable GitlabSecurity/PublicSend + metadata.set_attribute_synced(key, true) + else + metadata.set_attribute_synced(key, false) + end + end + + metadata.provider = auth_hash.provider + end + + if creating_linked_ldap_user? && gl_user.email == ldap_person.email.first + metadata.set_attribute_synced(:email, true) + metadata.provider = ldap_person.provider + end + end + + def clear_user_synced_attributes_metadata + gl_user&.user_synced_attributes_metadata&.destroy + end + + def log + Gitlab::AppLogger + end + + def oauth_provider_disabled? + Gitlab::CurrentSettings.current_application_settings + .disabled_oauth_sign_in_sources + .include?(auth_hash.provider) + end + end + end + end +end diff --git a/lib/gitlab/auth/saml/auth_hash.rb b/lib/gitlab/auth/saml/auth_hash.rb new file mode 100644 index 00000000000..c345a7e3f6c --- /dev/null +++ b/lib/gitlab/auth/saml/auth_hash.rb @@ -0,0 +1,19 @@ +module Gitlab + module Auth + module Saml + class AuthHash < Gitlab::Auth::OAuth::AuthHash + def groups + Array.wrap(get_raw(Gitlab::Auth::Saml::Config.groups)) + end + + private + + def get_raw(key) + # Needs to call `all` because of https://git.io/vVo4u + # otherwise just the first value is returned + auth_hash.extra[:raw_info].all[key] + end + end + end + end +end diff --git a/lib/gitlab/auth/saml/config.rb b/lib/gitlab/auth/saml/config.rb new file mode 100644 index 00000000000..e654e7fe438 --- /dev/null +++ b/lib/gitlab/auth/saml/config.rb @@ -0,0 +1,21 @@ +module Gitlab + module Auth + module Saml + class Config + class << self + def options + Gitlab.config.omniauth.providers.find { |provider| provider.name == 'saml' } + end + + def groups + options[:groups_attribute] + end + + def external_groups + options[:external_groups] + end + end + end + end + end +end diff --git a/lib/gitlab/auth/saml/user.rb b/lib/gitlab/auth/saml/user.rb new file mode 100644 index 00000000000..d4024e9ec39 --- /dev/null +++ b/lib/gitlab/auth/saml/user.rb @@ -0,0 +1,52 @@ +# SAML extension for User model +# +# * Find GitLab user based on SAML uid and provider +# * Create new user from SAML data +# +module Gitlab + module Auth + module Saml + class User < Gitlab::Auth::OAuth::User + def save + super('SAML') + end + + def find_user + user = find_by_uid_and_provider + + user ||= find_by_email if auto_link_saml_user? + user ||= find_or_build_ldap_user if auto_link_ldap_user? + user ||= build_new_user if signup_enabled? + + if external_users_enabled? && user + # Check if there is overlap between the user's groups and the external groups + # setting then set user as external or internal. + user.external = !(auth_hash.groups & Gitlab::Auth::Saml::Config.external_groups).empty? + end + + user + end + + def changed? + return true unless gl_user + + gl_user.changed? || gl_user.identities.any?(&:changed?) + end + + protected + + def auto_link_saml_user? + Gitlab.config.omniauth.auto_link_saml_user + end + + def external_users_enabled? + !Gitlab::Auth::Saml::Config.external_groups.nil? + end + + def auth_hash=(auth_hash) + @auth_hash = Gitlab::Auth::Saml::AuthHash.new(auth_hash) + end + end + end + end +end |