Skip to content

Commit 52b78e2

Browse files
author
Dave Sims
authored
Merge pull request #95 from github/support_referral_chasing
Support referral chasing for Active Directory member validation
2 parents 8d66502 + 60e7b7f commit 52b78e2

18 files changed

+666
-8
lines changed

.travis.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
language: ruby
22
rvm:
3-
- 1.9.3
3+
- 2.0.0
44
- 2.1.0
55

66
env:
77
- TESTENV=openldap
88
- TESTENV=apacheds
99

10+
# https://docs.travis-ci.com/user/hosts/
11+
addons:
12+
hosts:
13+
- ad1.ghe.dev
14+
- ad2.ghe.dev
15+
1016
install:
1117
- if [ "$TESTENV" = "openldap" ]; then ./script/install-openldap; fi
1218
- bundle install

Gemfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,7 @@ gemspec
66
group :test, :development do
77
gem "byebug", :platforms => [:mri_20, :mri_21]
88
end
9+
10+
group :test do
11+
gem "mocha"
12+
end

lib/github/ldap.rb

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
require 'github/ldap/instrumentation'
1111
require 'github/ldap/member_search'
1212
require 'github/ldap/membership_validators'
13+
require 'github/ldap/user_search/default'
14+
require 'github/ldap/user_search/active_directory'
15+
require 'github/ldap/connection_cache'
16+
require 'github/ldap/referral_chaser'
17+
require 'github/ldap/url'
1318

1419
module GitHub
1520
class Ldap
@@ -38,11 +43,17 @@ class Ldap
3843
#
3944
# Returns the return value of the block.
4045
def_delegator :@connection, :open
46+
def_delegator :@connection, :host
4147

4248
attr_reader :uid, :search_domains, :virtual_attributes,
4349
:membership_validator,
4450
:member_search_strategy,
45-
:instrumentation_service
51+
:instrumentation_service,
52+
:user_search_strategy,
53+
:connection,
54+
:admin_user,
55+
:admin_password,
56+
:port
4657

4758
# Build a new GitHub::Ldap instance
4859
#
@@ -69,6 +80,11 @@ class Ldap
6980
def initialize(options = {})
7081
@uid = options[:uid] || "sAMAccountName"
7182

83+
# Keep a reference to these as default auth for a Global Catalog if needed
84+
@admin_user = options[:admin_user]
85+
@admin_password = options[:admin_password]
86+
@port = options[:port]
87+
7288
@connection = Net::LDAP.new({
7389
host: options[:host],
7490
port: options[:port],
@@ -98,6 +114,9 @@ def initialize(options = {})
98114
# configure both the membership validator and the member search strategies
99115
configure_search_strategy(options[:search_strategy])
100116

117+
# configure the strategy used by Domain#user? to look up a user entry for login
118+
configure_user_search_strategy(options[:user_search_strategy])
119+
101120
# enables instrumenting queries
102121
@instrumentation_service = options[:instrumentation_service]
103122
end
@@ -281,6 +300,29 @@ def configure_membership_validation_strategy(strategy = nil)
281300
end
282301
end
283302

303+
# Internal: Set the user search strategy that will be used by
304+
# Domain#user?.
305+
#
306+
# strategy - Can be either 'default' or 'global_catalog'.
307+
# 'default' strategy will search the configured
308+
# domain controller with a search base relative
309+
# to the controller's domain context.
310+
# 'global_catalog' will search the entire forest
311+
# using Active Directory's Global Catalog
312+
# functionality.
313+
def configure_user_search_strategy(strategy)
314+
@user_search_strategy = begin
315+
case strategy.to_s
316+
when "default"
317+
GitHub::Ldap::UserSearch::Default.new(self)
318+
when "global_catalog"
319+
GitHub::Ldap::UserSearch::ActiveDirectory.new(self)
320+
else
321+
GitHub::Ldap::UserSearch::Default.new(self)
322+
end
323+
end
324+
end
325+
284326
# Internal: Configure the member search strategy.
285327
#
286328
#

lib/github/ldap/connection_cache.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
module GitHub
2+
class Ldap
3+
4+
# A simple cache of GitHub::Ldap objects to prevent creating multiple
5+
# instances of connections that point to the same URI/host.
6+
class ConnectionCache
7+
8+
# Public - Create or return cached instance of GitHub::Ldap created with options,
9+
# where the cache key is the value of options[:host].
10+
#
11+
# options - Initialization attributes suitable for creating a new connection with
12+
# GitHub::Ldap.new(options)
13+
#
14+
# Returns true or false.
15+
def self.get_connection(options={})
16+
@cache ||= self.new
17+
@cache.get_connection(options)
18+
end
19+
20+
def get_connection(options)
21+
@connections ||= {}
22+
@connections[options[:host]] ||= GitHub::Ldap.new(options)
23+
end
24+
end
25+
end
26+
end

lib/github/ldap/domain.rb

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,7 @@ def valid_login?(login, password)
115115
# Returns the user if the login matches any `uid`.
116116
# Returns nil if there are no matches.
117117
def user?(login, search_options = {})
118-
options = search_options.merge \
119-
filter: login_filter(@uid, login),
120-
size: 1
121-
search(options).first
118+
@ldap.user_search_strategy.perform(login, @base_name, @uid, search_options).first
122119
end
123120

124121
# Check if a user can be bound with a password.

lib/github/ldap/membership_validators/active_directory.rb

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,23 @@ def perform(entry)
2424
# Sets the entry to the base and scopes the search to the base,
2525
# according to the source documentation, found here:
2626
# http://msdn.microsoft.com/en-us/library/aa746475(v=vs.85).aspx
27-
matched = ldap.search \
27+
#
28+
# Use ReferralChaser to chase any potential referrals for an entry that may be owned by a different
29+
# domain controller.
30+
matched = referral_chaser.search \
2831
filter: membership_in_chain_filter(entry),
2932
base: entry.dn,
3033
scope: Net::LDAP::SearchScope_BaseObject,
34+
return_referrals: true,
3135
attributes: ATTRS
3236

3337
# membership validated if entry was matched and returned as a result
3438
# Active Directory DNs are case-insensitive
35-
matched.map { |m| m.dn.downcase }.include?(entry.dn.downcase)
39+
Array(matched).map { |m| m.dn.downcase }.include?(entry.dn.downcase)
40+
end
41+
42+
def referral_chaser
43+
@referral_chaser ||= GitHub::Ldap::ReferralChaser.new(@ldap)
3644
end
3745

3846
# Internal: Constructs a membership filter using the "in chain"

lib/github/ldap/referral_chaser.rb

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
module GitHub
2+
class Ldap
3+
4+
# This class adds referral chasing capability to a GitHub::Ldap connection.
5+
#
6+
# See: https://technet.microsoft.com/en-us/library/cc978014.aspx
7+
# http://www.umich.edu/~dirsvcs/ldap/doc/other/ldap-ref.html
8+
#
9+
class ReferralChaser
10+
11+
# Public - Creates a ReferralChaser that decorates an instance of GitHub::Ldap
12+
# with additional functionality to the #search method, allowing it to chase
13+
# any referral entries and aggregate the results into a single response.
14+
#
15+
# connection - The instance of GitHub::Ldap to use for searching. Will use
16+
# the connection's authentication, (admin_user and admin_password) as credentials
17+
# for connecting to referred domain controllers.
18+
def initialize(connection)
19+
@connection = connection
20+
@admin_user = connection.admin_user
21+
@admin_password = connection.admin_password
22+
@port = connection.port
23+
end
24+
25+
# Public - Search the domain controller represented by this instance's connection.
26+
# If a referral is returned, search only one of the domain controllers indicated
27+
# by the referral entries, per RFC 4511 (https://tools.ietf.org/html/rfc4511):
28+
#
29+
# "If the client wishes to progress the operation, it contacts one of
30+
# the supported services found in the referral. If multiple URIs are
31+
# present, the client assumes that any supported URI may be used to
32+
# progress the operation."
33+
#
34+
# options - is a hash with the same options that Net::LDAP::Connection#search supports.
35+
# Referral searches will use the given options, but will replace options[:base]
36+
# with the referral URL's base search dn.
37+
#
38+
# Does not take a block argument as GitHub::Ldap and Net::LDAP::Connection#search do.
39+
#
40+
# Will not recursively follow any subsequent referrals.
41+
#
42+
# Returns an Array of Net::LDAP::Entry.
43+
def search(options)
44+
search_results = []
45+
referral_entries = []
46+
47+
search_results = connection.search(options) do |entry|
48+
if entry && entry[:search_referrals]
49+
referral_entries << entry
50+
end
51+
end
52+
53+
unless referral_entries.empty?
54+
entry = referral_entries.first
55+
referral_string = entry[:search_referrals].first
56+
if GitHub::Ldap::URL.valid?(referral_string)
57+
referral = Referral.new(referral_string, admin_user, admin_password, port)
58+
search_results = referral.search(options)
59+
end
60+
end
61+
62+
Array(search_results)
63+
end
64+
65+
private
66+
67+
attr_reader :connection, :admin_user, :admin_password, :port
68+
69+
# Represents a referral entry from an LDAP search result. Constructs a corresponding
70+
# GitHub::Ldap object from the paramaters on the referral_url and provides a #search
71+
# method to continue the search on the referred domain.
72+
class Referral
73+
def initialize(referral_url, admin_user, admin_password, port=nil)
74+
url = GitHub::Ldap::URL.new(referral_url)
75+
@search_base = url.dn
76+
77+
connection_options = {
78+
host: url.host,
79+
port: port || url.port,
80+
scope: url.scope,
81+
admin_user: admin_user,
82+
admin_password: admin_password
83+
}
84+
85+
@connection = GitHub::Ldap::ConnectionCache.get_connection(connection_options)
86+
end
87+
88+
# Search the referred domain controller with options, merging in the referred search
89+
# base DN onto options[:base].
90+
def search(options)
91+
connection.search(options.merge(base: search_base))
92+
end
93+
94+
attr_reader :search_base, :connection
95+
end
96+
end
97+
end
98+
end

lib/github/ldap/url.rb

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
module GitHub
2+
class Ldap
3+
4+
# This class represents an LDAP URL
5+
#
6+
# See: https://tools.ietf.org/html/rfc4516#section-2
7+
# https://docs.oracle.com/cd/E19957-01/817-6707/urls.html
8+
#
9+
class URL
10+
extend Forwardable
11+
SCOPES = {
12+
"base" => Net::LDAP::SearchScope_BaseObject,
13+
"one" => Net::LDAP::SearchScope_SingleLevel,
14+
"sub" => Net::LDAP::SearchScope_WholeSubtree
15+
}
16+
SCOPES.default = Net::LDAP::SearchScope_BaseObject
17+
18+
attr_reader :dn, :attributes, :scope, :filter
19+
20+
def_delegators :@uri, :port, :host, :scheme
21+
22+
# Public - Creates a new GitHub::Ldap::URL object with :port, :host and :scheme
23+
# delegated to a URI object parsed from url_string, and then parses the
24+
# query params according to the LDAP specification.
25+
#
26+
# url_string - An LDAP URL string.
27+
# returns - a GitHub::Ldap::URL with the following attributes:
28+
# host - Name or IP of the LDAP server.
29+
# port - The given port, defaults to 389.
30+
# dn - The base search DN.
31+
# attributes - The comma-delimited list of attributes to be returned.
32+
# scope - The scope of the search.
33+
# filter - Search filter to apply to entries within the specified scope of the search.
34+
#
35+
# Supported LDAP URL strings look like this, where sections in brackets are optional:
36+
#
37+
# ldap[s]://[hostport][/[dn[?[attributes][?[scope][?[filter]]]]]]
38+
#
39+
# where:
40+
#
41+
# hostport is a host name with an optional ":portnumber"
42+
# dn is the base DN to be used for an LDAP search operation
43+
# attributes is a comma separated list of attributes to be retrieved
44+
# scope is one of these three strings: base one sub (default=base)
45+
# filter is LDAP search filter as used in a call to ldap_search
46+
#
47+
# For example:
48+
#
49+
# ldap://dc4.ghe.local:456/CN=Maggie,DC=dc4,DC=ghe,DC=local?cn,mail?base?(cn=Charlie)
50+
#
51+
def initialize(url_string)
52+
if !self.class.valid?(url_string)
53+
raise InvalidLdapURLException.new("Invalid LDAP URL: #{url_string}")
54+
end
55+
@uri = URI(url_string)
56+
@dn = URI.unescape(@uri.path.sub(/^\//, ""))
57+
if @uri.query
58+
@attributes, @scope, @filter = @uri.query.split("?")
59+
end
60+
end
61+
62+
def self.valid?(url_string)
63+
url_string =~ URI::regexp && ["ldap", "ldaps"].include?(URI(url_string).scheme)
64+
end
65+
66+
# Maps the returned scope value from the URL to one of Net::LDAP::Scopes
67+
#
68+
# The URL scope value can be one of:
69+
# "base" - retrieves information only about the DN (base_dn) specified.
70+
# "one" - retrieves information about entries one level below the DN (base_dn) specified. The base entry is not included in this scope.
71+
# "sub" - retrieves information about entries at all levels below the DN (base_dn) specified. The base entry is included in this scope.
72+
#
73+
# Which will map to one of the following Net::LDAP::Scopes:
74+
# SearchScope_BaseObject = 0
75+
# SearchScope_SingleLevel = 1
76+
# SearchScope_WholeSubtree = 2
77+
#
78+
# If no scope or an invalid scope is given, defaults to SearchScope_BaseObject
79+
def net_ldap_scope
80+
Net::LDAP::SearchScopes[SCOPES[scope]]
81+
end
82+
83+
class InvalidLdapURLException < Exception; end
84+
end
85+
end
86+
end
87+

0 commit comments

Comments
 (0)