Skip to content

Commit 871b530

Browse files
author
David Calavera
committed
Merge pull request github#3 from github/decouple_user_domain
Decouple user domain
2 parents 61b2cec + 6c1dd71 commit 871b530

File tree

5 files changed

+238
-170
lines changed

5 files changed

+238
-170
lines changed

README.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Or install it yourself as:
2020

2121
## Usage
2222

23+
### Initialization
24+
2325
GitHub-Ldap let you use an external ldap server to authenticate your users with.
2426

2527
There are a few configuration options required to use this adapter:
@@ -29,7 +31,6 @@ There are a few configuration options required to use this adapter:
2931
* admin_user: is the the ldap administrator user. Required to perform search operation.
3032
* admin_password: is the password for the administrator user. Simple authentication is required on the server.
3133
* encryptation: is the encryptation protocol, disabled by default. The valid options are `ssl` and `tls`.
32-
* user_domain: is the default ldap domain base.
3334
* uid: is the field name in the ldap server used to authenticate your users, in ActiveDirectory this is `sAMAccountName`.
3435

3536
Initialize a new adapter using those required options:
@@ -38,7 +39,28 @@ Initialize a new adapter using those required options:
3839
ldap = GitHub::Ldap.new options
3940
```
4041

41-
## Testing
42+
### Quering
43+
44+
Searches are performed against an individual domain base, so the first step is to get a new `GitHub::Ldap::Domain` object for the connection:
45+
46+
```ruby
47+
ldap = GitHub::Ldap.new options
48+
domain = ldap.domain("dc=github,dc=com")
49+
```
50+
51+
When we have the domain, we can check if a user can log in with a given password:
52+
53+
```ruby
54+
domain.valid_login? 'calavera', 'secret'
55+
```
56+
57+
Or whether a user is member of the given groups:
58+
59+
```ruby
60+
domain.is_member? 'uid=calavera,dc=github,dc=com', %w(Enterprise)
61+
```
62+
63+
### Testing support
4264

4365
GitHub-Ldap uses [ladle](https://github.com/NUBIC/ladle) for testing. Ladle is not required by default, so you'll need to add it to your gemfile separatedly and require it.
4466

lib/github/ldap.rb

Lines changed: 15 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,23 @@
11
module GitHub
22
class Ldap
33
require 'net/ldap'
4+
require 'github/ldap/domain'
45

56
def initialize(options = {})
6-
@user_domain = options[:user_domain]
7-
@uid = options[:uid] || "sAMAccountName"
7+
@uid = options[:uid] || "sAMAccountName"
88

9-
@ldap = Net::LDAP.new({
9+
@connection = Net::LDAP.new({
1010
host: options[:host],
1111
port: options[:port]
1212
})
1313

14-
@ldap.authenticate(options[:admin_user], options[:admin_password])
14+
@connection.authenticate(options[:admin_user], options[:admin_password])
1515

1616
if encryption = check_encryption(options[:encryptation])
17-
@ldap.encryption(encryption)
17+
@connection.encryption(encryption)
1818
end
1919
end
2020

21-
# Generate a filter to get the configured groups in the ldap server.
22-
# Takes the list of the group names and generate a filter for the groups
23-
# with cn that match and also include members:
24-
#
25-
# group_names: is an array of group CNs.
26-
#
27-
# Returns the ldap filter.
28-
def group_filter(group_names)
29-
or_filters = group_names.map {|g| Net::LDAP::Filter.eq("cn", g)}.reduce(:|)
30-
Net::LDAP::Filter.pres("member") & or_filters
31-
end
32-
33-
# List the groups in the ldap server that match the configured ones.
34-
#
35-
# group_names: is an array of group CNs.
36-
#
37-
# Returns a list of ldap entries for the configured groups.
38-
def groups(group_names)
39-
filter = group_filter(group_names)
40-
41-
@ldap.search(base: @user_domain,
42-
attributes: %w{ou cn dn sAMAccountName member},
43-
filter: filter)
44-
end
45-
46-
# List the groups that a user is member of.
47-
#
48-
# user_dn: is the dn for the user ldap entry.
49-
# group_names: is an array of group CNs.
50-
#
51-
# Return an Array with the groups that the given user is member of that belong to the given group list.
52-
def membership(user_dn, group_names)
53-
or_filters = group_names.map {|g| Net::LDAP::Filter.eq("cn", g)}.reduce(:|)
54-
member_filter = Net::LDAP::Filter.eq("member", user_dn) & or_filters
55-
56-
@ldap.search(base: @user_domain,
57-
attributes: %w{ou cn dn sAMAccountName member},
58-
filter: member_filter)
59-
end
60-
61-
62-
# Check if the user is include in any of the configured groups.
63-
#
64-
# user_dn: is the dn for the user ldap entry.
65-
# group_names: is an array of group CNs.
66-
#
67-
# Returns true if the user belongs to any of the groups.
68-
# Returns false otherwise.
69-
def is_member?(user_dn, group_names)
70-
return true if group_names.nil?
71-
return true if group_names.empty?
72-
73-
user_membership = membership(user_dn, group_names)
74-
75-
!user_membership.empty?
76-
end
77-
78-
# Check if the user credentials are valid.
79-
#
80-
# login: is the user's login.
81-
# password: is the user's password.
82-
#
83-
# Returns a Ldap::Entry if the credentials are valid.
84-
# Returns nil if the credentials are invalid.
85-
def valid_login?(login, password)
86-
result = @ldap.bind_as(
87-
base: @user_domain,
88-
limit: 1,
89-
filter: Net::LDAP::Filter.eq(@uid, login),
90-
password: password)
91-
92-
return result.first if result.is_a?(Array)
93-
end
94-
95-
# Authenticate a user with the ldap server.
96-
#
97-
# login: is the user's login. This method doesn't accept email identifications.
98-
# password: is the user's password.
99-
# group_names: is an array of group CNs.
100-
#
101-
# Returns the user info if the credentials are valid and there are no groups configured.
102-
# Returns the user info if the credentials are valid and the user belongs to a configured group.
103-
# Returns nil if the credentials are invalid
104-
def authenticate!(login, password, group_names = nil)
105-
user = valid_login?(login, password)
106-
107-
return user if user && is_member?(user.dn, group_names)
108-
end
109-
11021
# Check the legacy auth configuration options (before David's war with omniauth)
11122
# to determine whether to use encryptation or not.
11223
#
@@ -131,7 +42,16 @@ def check_encryption(encryption)
13142
# Return false if the authentication settings are not valid.
13243
# Raises an Net::LDAP::LdapError if the connection fails.
13344
def test_connection
134-
@ldap.bind
45+
@connection.bind
46+
end
47+
48+
# Creates a new domain object to perform operations
49+
#
50+
# base_name: is the dn of the base root.
51+
#
52+
# Returns a new Domain object.
53+
def domain(base_name)
54+
Domain.new(base_name, @connection, @uid)
13555
end
13656
end
13757
end

lib/github/ldap/domain.rb

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
module GitHub
2+
class Ldap
3+
# A domain represents the base object for an ldap tree.
4+
# It encapsulates the operations that you can perform against a tree, authenticating users, for instance.
5+
#
6+
# This makes possible to reuse a server connection to perform operations with two different domain bases.
7+
#
8+
# To get a domain, you'll need to create a `Ldap` object and then call the method `domain` with the name of the base.
9+
#
10+
# For example:
11+
#
12+
# domain = GitHub::Ldap.new(options).domain("dc=github,dc=com")
13+
#
14+
class Domain
15+
def initialize(base_name, connection, uid)
16+
@base_name, @connection, @uid = base_name, connection, uid
17+
end
18+
19+
# Generate a filter to get the configured groups in the ldap server.
20+
# Takes the list of the group names and generate a filter for the groups
21+
# with cn that match and also include members:
22+
#
23+
# group_names: is an array of group CNs.
24+
#
25+
# Returns the ldap filter.
26+
def group_filter(group_names)
27+
or_filters = group_names.map {|g| Net::LDAP::Filter.eq("cn", g)}.reduce(:|)
28+
Net::LDAP::Filter.pres("member") & or_filters
29+
end
30+
31+
# List the groups in the ldap server that match the configured ones.
32+
#
33+
# group_names: is an array of group CNs.
34+
#
35+
# Returns a list of ldap entries for the configured groups.
36+
def groups(group_names)
37+
filter = group_filter(group_names)
38+
39+
@connection.search(base: @base_name,
40+
attributes: %w{ou cn dn sAMAccountName member},
41+
filter: filter)
42+
end
43+
44+
# List the groups that a user is member of.
45+
#
46+
# user_dn: is the dn for the user ldap entry.
47+
# group_names: is an array of group CNs.
48+
#
49+
# Return an Array with the groups that the given user is member of that belong to the given group list.
50+
def membership(user_dn, group_names)
51+
or_filters = group_names.map {|g| Net::LDAP::Filter.eq("cn", g)}.reduce(:|)
52+
member_filter = Net::LDAP::Filter.eq("member", user_dn) & or_filters
53+
54+
@connection.search(base: @base_name,
55+
attributes: %w{ou cn dn sAMAccountName member},
56+
filter: member_filter)
57+
end
58+
59+
# Check if the user is include in any of the configured groups.
60+
#
61+
# user_dn: is the dn for the user ldap entry.
62+
# group_names: is an array of group CNs.
63+
#
64+
# Returns true if the user belongs to any of the groups.
65+
# Returns false otherwise.
66+
def is_member?(user_dn, group_names)
67+
return true if group_names.nil?
68+
return true if group_names.empty?
69+
70+
user_membership = membership(user_dn, group_names)
71+
72+
!user_membership.empty?
73+
end
74+
75+
# Check if the user credentials are valid.
76+
#
77+
# login: is the user's login.
78+
# password: is the user's password.
79+
#
80+
# Returns a Ldap::Entry if the credentials are valid.
81+
# Returns nil if the credentials are invalid.
82+
def valid_login?(login, password)
83+
result = @connection.bind_as(
84+
base: @base_name,
85+
limit: 1,
86+
filter: Net::LDAP::Filter.eq(@uid, login),
87+
password: password)
88+
89+
return result.first if result.is_a?(Array)
90+
end
91+
92+
# Authenticate a user with the ldap server.
93+
#
94+
# login: is the user's login. This method doesn't accept email identifications.
95+
# password: is the user's password.
96+
# group_names: is an array of group CNs.
97+
#
98+
# Returns the user info if the credentials are valid and there are no groups configured.
99+
# Returns the user info if the credentials are valid and the user belongs to a configured group.
100+
# Returns nil if the credentials are invalid
101+
def authenticate!(login, password, group_names = nil)
102+
user = valid_login?(login, password)
103+
104+
return user if user && is_member?(user.dn, group_names)
105+
end
106+
end
107+
end
108+
end

test/domain_test.rb

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
require 'test_helper'
2+
3+
class GitHubLdapDomainTest < Minitest::Test
4+
def setup
5+
GitHub::Ldap.start_server
6+
7+
@options = GitHub::Ldap.server_options.merge \
8+
host: 'localhost',
9+
uid: 'uid'
10+
11+
@domain = GitHub::Ldap.new(@options).domain("dc=github,dc=com")
12+
end
13+
14+
def teardown
15+
GitHub::Ldap.stop_server
16+
end
17+
18+
def test_user_valid_login
19+
user = @domain.valid_login?('calavera', 'secret')
20+
assert_equal 'uid=calavera,dc=github,dc=com', user.dn
21+
end
22+
23+
def test_user_with_invalid_password
24+
assert !@domain.valid_login?('calavera', 'foo'),
25+
"Login `calavera` expected to be invalid with password `foo`"
26+
end
27+
28+
def test_user_with_invalid_login
29+
assert !@domain.valid_login?('bar', 'foo'),
30+
"Login `bar` expected to be invalid with password `foo`"
31+
end
32+
33+
def test_groups_in_server
34+
assert_equal 2, @domain.groups(%w(Enterprise People)).size
35+
end
36+
37+
def test_user_in_group
38+
user = @domain.valid_login?('calavera', 'secret')
39+
40+
assert @domain.is_member?(user.dn, %w(Enterprise People)),
41+
"Expected `Enterprise` or `Poeple` to include the member `#{user.dn}`"
42+
end
43+
44+
def test_user_not_in_different_group
45+
user = @domain.valid_login?('calavera', 'secret')
46+
47+
assert !@domain.is_member?(user.dn, %w(People)),
48+
"Expected `Poeple` not to include the member `#{user.dn}`"
49+
end
50+
51+
def test_user_without_group
52+
user = @domain.valid_login?('ldaptest', 'secret')
53+
54+
assert !@domain.is_member?(user.dn, %w(People)),
55+
"Expected `Poeple` not to include the member `#{user.dn}`"
56+
end
57+
58+
def test_authenticate_doesnt_return_invalid_users
59+
user = @domain.authenticate!('calavera', 'secret')
60+
assert_equal 'uid=calavera,dc=github,dc=com', user.dn
61+
end
62+
63+
def test_authenticate_doesnt_return_invalid_users
64+
assert !@domain.authenticate!('calavera', 'foo'),
65+
"Expected `authenticate!` to not return an invalid user"
66+
end
67+
68+
def test_authenticate_check_valid_user_and_groups
69+
user = @domain.authenticate!('calavera', 'secret', %w(Enterprise People))
70+
71+
assert_equal 'uid=calavera,dc=github,dc=com', user.dn
72+
end
73+
74+
def test_authenticate_doesnt_return_valid_users_in_different_groups
75+
assert !@domain.authenticate!('calavera', 'secret', %w(People)),
76+
"Expected `authenticate!` to not return an user"
77+
end
78+
79+
def test_membership_empty_for_non_members
80+
assert @domain.membership('uid=calavera,dc=github,dc=com', %w(People)).empty?,
81+
"Expected `calavera` not to be a member of `People`."
82+
end
83+
84+
def test_membership_groups_for_members
85+
groups = @domain.membership('uid=calavera,dc=github,dc=com', %w(Enterprise People))
86+
87+
assert_equal 1, groups.size
88+
assert_equal 'cn=Enterprise,ou=Group,dc=github,dc=com', groups.first.dn
89+
end
90+
end
91+

0 commit comments

Comments
 (0)