ldap_lookup 0.1.8 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.env.example +25 -0
- data/.gitignore +5 -0
- data/.rspec +3 -0
- data/.rspec_status +46 -0
- data/Gemfile.lock +5 -3
- data/README.md +177 -21
- data/SETUP.md +117 -0
- data/config/initializers/ldap_lookup.rb.example +32 -0
- data/ldap_lookup.gemspec +3 -2
- data/ldaptest.rb +24 -5
- data/lib/ldap_lookup/version.rb +1 -1
- data/lib/ldap_lookup.rb +392 -44
- metadata +28 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 393d3bdcafb4cadf5648c6916122ebfb7b0c6fefe79c5d50b6c6c57f6d6997cc
|
|
4
|
+
data.tar.gz: de8be14be88d2d4e3102971cf62d4a316cdc6a0aaa7e6268aee62659723e5ca2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7dd298be615e445056d806602b27cb05e01a0783556fe09c32d1c5599ff877a18c286d1d2e845f1f5ec30c4b08745668ec5fd8f2717e4393cc7c45dd7e33254d
|
|
7
|
+
data.tar.gz: 6bb46112525a64c3c63fd1cb2d356e151ff48154515684040d5c1519464619681942f36a45ba275d0749abcd5b04afd81bc28ddb9a72d9c403eba5dc093d0970
|
data/.env.example
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# LDAP Configuration for Testing
|
|
2
|
+
# Copy this file to .env and fill in your actual credentials
|
|
3
|
+
# The .env file is gitignored and will not be committed
|
|
4
|
+
|
|
5
|
+
# Your UM uniqname (username)
|
|
6
|
+
LDAP_USERNAME=your_uniqname
|
|
7
|
+
|
|
8
|
+
# Your UM password
|
|
9
|
+
LDAP_PASSWORD=your_password
|
|
10
|
+
|
|
11
|
+
# Leave LDAP_USERNAME and LDAP_PASSWORD unset for anonymous binds
|
|
12
|
+
|
|
13
|
+
# Optional: Override default LDAP settings
|
|
14
|
+
# LDAP_HOST=ldap.umich.edu
|
|
15
|
+
# LDAP_PORT=389 # Use 389 for STARTTLS or 636 for LDAPS
|
|
16
|
+
# LDAP_BASE=dc=umich,dc=edu
|
|
17
|
+
# LDAP_ENCRYPTION=start_tls # Use 'start_tls' for port 389 or 'simple_tls' for port 636 (LDAPS)
|
|
18
|
+
# LDAP_DEPT_ATTRIBUTE=umichPostalAddressData
|
|
19
|
+
# LDAP_GROUP_ATTRIBUTE=umichGroupEmail
|
|
20
|
+
# LDAP_TLS_VERIFY=true # Set to false to disable cert verification (dev only)
|
|
21
|
+
# LDAP_CA_CERT=/path/to/ca-bundle.pem
|
|
22
|
+
#
|
|
23
|
+
# If STARTTLS (port 389) doesn't work, try LDAPS:
|
|
24
|
+
# LDAP_PORT=636
|
|
25
|
+
# LDAP_ENCRYPTION=simple_tls
|
data/.gitignore
CHANGED
data/.rspec
ADDED
data/.rspec_status
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
example_id | status | run_time |
|
|
2
|
+
------------------------------------- | ------ | --------------- |
|
|
3
|
+
./spec/configuration_spec.rb[1:1:1:1] | passed | 0.00021 seconds |
|
|
4
|
+
./spec/configuration_spec.rb[1:1:1:2] | passed | 0.00004 seconds |
|
|
5
|
+
./spec/configuration_spec.rb[1:1:1:3] | passed | 0.00004 seconds |
|
|
6
|
+
./spec/configuration_spec.rb[1:1:2:1] | passed | 0.00149 seconds |
|
|
7
|
+
./spec/configuration_spec.rb[1:1:2:2] | passed | 0.00004 seconds |
|
|
8
|
+
./spec/configuration_spec.rb[1:1:3:1] | passed | 0.00004 seconds |
|
|
9
|
+
./spec/configuration_spec.rb[1:1:3:2] | passed | 0.00005 seconds |
|
|
10
|
+
./spec/configuration_spec.rb[1:2:1] | passed | 0.00002 seconds |
|
|
11
|
+
./spec/configuration_spec.rb[1:2:2] | passed | 0.00003 seconds |
|
|
12
|
+
./spec/configuration_spec.rb[1:3:1] | passed | 0.00003 seconds |
|
|
13
|
+
./spec/configuration_spec.rb[1:3:2] | passed | 0.00003 seconds |
|
|
14
|
+
./spec/configuration_spec.rb[1:3:3] | passed | 0.00003 seconds |
|
|
15
|
+
./spec/ldap_lookup_spec.rb[1:1:1:1] | passed | 0.63596 seconds |
|
|
16
|
+
./spec/ldap_lookup_spec.rb[1:1:2:1] | passed | 0.61562 seconds |
|
|
17
|
+
./spec/ldap_lookup_spec.rb[1:1:3:1] | passed | 0.60601 seconds |
|
|
18
|
+
./spec/ldap_lookup_spec.rb[1:2:1:1] | passed | 0.64499 seconds |
|
|
19
|
+
./spec/ldap_lookup_spec.rb[1:2:2:1] | passed | 0.6525 seconds |
|
|
20
|
+
./spec/ldap_lookup_spec.rb[1:3:1:1] | passed | 0.66369 seconds |
|
|
21
|
+
./spec/ldap_lookup_spec.rb[1:3:2:1] | passed | 1.29 seconds |
|
|
22
|
+
./spec/ldap_lookup_spec.rb[1:4:1:1] | passed | 0.64139 seconds |
|
|
23
|
+
./spec/ldap_lookup_spec.rb[1:4:2:1] | passed | 0.64128 seconds |
|
|
24
|
+
./spec/ldap_lookup_spec.rb[1:5:1:1] | passed | 1.69 seconds |
|
|
25
|
+
./spec/ldap_lookup_spec.rb[1:5:2:1] | passed | 0.75576 seconds |
|
|
26
|
+
./spec/ldap_lookup_spec.rb[1:5:3:1] | passed | 0.70272 seconds |
|
|
27
|
+
./spec/ldap_lookup_spec.rb[1:5:4:1] | passed | 0.74101 seconds |
|
|
28
|
+
./spec/ldap_lookup_spec.rb[1:6:1:1] | passed | 0.78507 seconds |
|
|
29
|
+
./spec/ldap_lookup_spec.rb[1:6:1:2] | passed | 0.77477 seconds |
|
|
30
|
+
./spec/ldap_lookup_spec.rb[1:6:1:3] | passed | 0.74474 seconds |
|
|
31
|
+
./spec/ldap_lookup_spec.rb[1:6:2:1] | passed | 0.70436 seconds |
|
|
32
|
+
./spec/ldap_lookup_spec.rb[1:7:1:1] | passed | 13.94 seconds |
|
|
33
|
+
./spec/ldap_lookup_spec.rb[1:7:1:2] | passed | 21.97 seconds |
|
|
34
|
+
./spec/ldap_lookup_spec.rb[1:7:1:3] | passed | 27.87 seconds |
|
|
35
|
+
./spec/ldap_lookup_spec.rb[1:7:2:1] | passed | 7.88 seconds |
|
|
36
|
+
./spec/ldap_lookup_spec.rb[1:8:1:1] | passed | 0.0026 seconds |
|
|
37
|
+
./spec/ldap_lookup_spec.rb[1:8:1:2] | passed | 0.00012 seconds |
|
|
38
|
+
./spec/ldap_lookup_spec.rb[1:8:1:3] | passed | 0.00009 seconds |
|
|
39
|
+
./spec/ldap_lookup_spec.rb[1:8:2:1] | passed | 0.0001 seconds |
|
|
40
|
+
./spec/ldap_lookup_spec.rb[1:8:3:1] | passed | 0.0001 seconds |
|
|
41
|
+
./spec/ldap_lookup_spec.rb[1:9:1:1] | passed | 0.0001 seconds |
|
|
42
|
+
./spec/ldap_lookup_spec.rb[1:9:2:1] | passed | 0.00009 seconds |
|
|
43
|
+
./spec/ldap_lookup_spec.rb[1:10:1:1] | passed | 0.525 seconds |
|
|
44
|
+
./spec/ldap_lookup_spec.rb[1:10:2:1] | passed | 0.46772 seconds |
|
|
45
|
+
./spec/ldap_lookup_spec.rb[1:11:1:1] | failed | 0.54755 seconds |
|
|
46
|
+
./spec/ldap_lookup_spec.rb[1:11:2:1] | passed | 0.47023 seconds |
|
data/Gemfile.lock
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
ldap_lookup (0.1.
|
|
5
|
-
net-ldap (~> 0.
|
|
4
|
+
ldap_lookup (0.1.8)
|
|
5
|
+
net-ldap (~> 0.18.0)
|
|
6
6
|
|
|
7
7
|
GEM
|
|
8
8
|
remote: https://rubygems.org/
|
|
9
9
|
specs:
|
|
10
10
|
diff-lcs (1.3)
|
|
11
|
-
|
|
11
|
+
dotenv (2.8.1)
|
|
12
|
+
net-ldap (0.18.0)
|
|
12
13
|
rake (13.0.1)
|
|
13
14
|
rspec (3.7.0)
|
|
14
15
|
rspec-core (~> 3.7.0)
|
|
@@ -29,6 +30,7 @@ PLATFORMS
|
|
|
29
30
|
|
|
30
31
|
DEPENDENCIES
|
|
31
32
|
bundler (~> 2.2.26)
|
|
33
|
+
dotenv (~> 2.8)
|
|
32
34
|
ldap_lookup!
|
|
33
35
|
rake (~> 13.0)
|
|
34
36
|
rspec (~> 3.7.0)
|
data/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# LdapLookup for Ruby [](https://badge.fury.io/rb/ldap_lookup)
|
|
2
2
|
|
|
3
3
|
### Description
|
|
4
|
-
This module is to be used for anonymous lookup of user attributes in the MCommunity service
|
|
4
|
+
This module is to be used for authenticated or anonymous lookup of user attributes in the MCommunity service provided at the University of Michigan. It supports authenticated LDAP binds with encryption as per UM IT Security requirements (effective Jan 20, 2026). It can be easily modified to use other LDAP server configurations.
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -9,23 +9,47 @@ This module is to be used for anonymous lookup of user attributes in the MCommun
|
|
|
9
9
|
|
|
10
10
|
Requirements:
|
|
11
11
|
* Ruby at least 2.0.0
|
|
12
|
-
* Gem 'net-ldap' ~> '0.
|
|
12
|
+
* Gem 'net-ldap' ~> '0.18.0'
|
|
13
13
|
> *The Net::LDAP (aka net-ldap) gem before 0.16.0 for Ruby has a Missing SSL Certificate Validation.*
|
|
14
14
|
|
|
15
15
|
To try the module out:
|
|
16
16
|
1. Clone the repo
|
|
17
|
-
2.
|
|
17
|
+
2. Copy the env template and set credentials: `cp .env.example .env`
|
|
18
|
+
3. Load the env vars into your shell (example):
|
|
19
|
+
```bash
|
|
20
|
+
set -a
|
|
21
|
+
source .env
|
|
22
|
+
set +a
|
|
23
|
+
```
|
|
24
|
+
4. Edit the configurations by opening ldaptest.rb and set the *CONFIGURATION BLOCK* to your environment (it reads from the `.env` values you just loaded).
|
|
18
25
|
<pre>
|
|
19
26
|
LdapLookup.configuration do |config|
|
|
20
|
-
config.host =
|
|
21
|
-
config.port =
|
|
22
|
-
config.base =
|
|
23
|
-
|
|
24
|
-
config.
|
|
27
|
+
config.host = ENV['LDAP_HOST'] || "ldap.umich.edu"
|
|
28
|
+
config.port = ENV['LDAP_PORT'] || "389"
|
|
29
|
+
config.base = ENV['LDAP_BASE'] || "dc=umich,dc=edu"
|
|
30
|
+
# Leave username/password unset for anonymous binds
|
|
31
|
+
config.username = ENV['LDAP_USERNAME']
|
|
32
|
+
config.password = ENV['LDAP_PASSWORD']
|
|
33
|
+
# Read encryption from ENV, default to start_tls
|
|
34
|
+
encryption_str = ENV['LDAP_ENCRYPTION'] || 'start_tls'
|
|
35
|
+
config.encryption = encryption_str.to_sym
|
|
36
|
+
config.dept_attribute = ENV['LDAP_DEPT_ATTRIBUTE'] || "umichPostalAddressData"
|
|
37
|
+
config.group_attribute = ENV['LDAP_GROUP_ATTRIBUTE'] || "umichGroupEmail"
|
|
38
|
+
# Enable LDAP debug logging in this test runner
|
|
39
|
+
debug_str = ENV['LDAP_DEBUG']
|
|
40
|
+
config.debug = debug_str ? debug_str.to_s.downcase == 'true' : true
|
|
25
41
|
end
|
|
26
42
|
</pre>
|
|
27
43
|
|
|
28
|
-
|
|
44
|
+
**Important:** As of January 20, 2026, UM LDAP requires:
|
|
45
|
+
- **Authenticated binds only** - Anonymous (unauthenticated) binds are not supported by UM LDAP
|
|
46
|
+
- Username and password are required for UM LDAP connections
|
|
47
|
+
- Encrypted connections (STARTTLS or LDAPS) are mandatory
|
|
48
|
+
- The gem uses LDAP "simple bind" authentication (authenticated with username/password)
|
|
49
|
+
|
|
50
|
+
The gem can also perform **anonymous binds** for LDAP servers that allow them. To use anonymous binds, leave `LDAP_USERNAME` and `LDAP_PASSWORD` unset.
|
|
51
|
+
|
|
52
|
+
5. run the ldaptest.rb script
|
|
29
53
|
```ruby
|
|
30
54
|
ruby ./ldaptest.rb
|
|
31
55
|
```
|
|
@@ -34,30 +58,129 @@ ruby ./ldaptest.rb
|
|
|
34
58
|
|
|
35
59
|
### Installation
|
|
36
60
|
|
|
61
|
+
#### Step 1: Add to Gemfile
|
|
62
|
+
|
|
37
63
|
Add this line to your application's Gemfile:
|
|
38
64
|
|
|
39
65
|
```ruby
|
|
40
66
|
gem 'ldap_lookup'
|
|
41
67
|
```
|
|
42
68
|
|
|
43
|
-
|
|
69
|
+
Then run:
|
|
44
70
|
|
|
45
|
-
|
|
71
|
+
```bash
|
|
72
|
+
bundle install
|
|
73
|
+
```
|
|
46
74
|
|
|
47
|
-
|
|
75
|
+
#### Step 2: Get LDAP Credentials
|
|
48
76
|
|
|
49
|
-
|
|
77
|
+
**For Production Applications (Recommended):**
|
|
78
|
+
Request a **service account** from your IT department. Service accounts are designed for automated applications and don't require password changes.
|
|
79
|
+
|
|
80
|
+
**For Development/Testing:**
|
|
81
|
+
You can use your personal UM uniqname and password temporarily, but switch to a service account for production.
|
|
82
|
+
|
|
83
|
+
#### Step 3: Configure the Gem
|
|
84
|
+
|
|
85
|
+
**For Rails Applications:**
|
|
86
|
+
|
|
87
|
+
Create `config/initializers/ldap_lookup.rb`:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
LdapLookup.configuration do |config|
|
|
91
|
+
# Server Configuration (defaults work for UM LDAP)
|
|
92
|
+
config.host = ENV.fetch('LDAP_HOST', 'ldap.umich.edu')
|
|
93
|
+
config.port = ENV.fetch('LDAP_PORT', '389')
|
|
94
|
+
config.base = ENV.fetch('LDAP_BASE', 'dc=umich,dc=edu')
|
|
95
|
+
|
|
96
|
+
# Authentication (optional for anonymous binds)
|
|
97
|
+
# Leave unset to use anonymous binds (if your LDAP server allows it)
|
|
98
|
+
config.username = ENV['LDAP_USERNAME']
|
|
99
|
+
config.password = ENV['LDAP_PASSWORD']
|
|
100
|
+
|
|
101
|
+
# If using a service account with custom bind DN, uncomment and set:
|
|
102
|
+
# config.bind_dn = 'cn=service-account,ou=Service Accounts,dc=umich,dc=edu'
|
|
103
|
+
|
|
104
|
+
# Encryption - REQUIRED (defaults to STARTTLS)
|
|
105
|
+
config.encryption = ENV.fetch('LDAP_ENCRYPTION', 'start_tls').to_sym
|
|
106
|
+
# Use :simple_tls for LDAPS on port 636
|
|
107
|
+
# TLS verification (defaults to true). Set LDAP_TLS_VERIFY=false only for local testing.
|
|
108
|
+
# Optional custom CA bundle: set LDAP_CA_CERT=/path/to/ca-bundle.pem
|
|
109
|
+
|
|
110
|
+
# Optional: Attribute Configuration
|
|
111
|
+
config.dept_attribute = ENV.fetch('LDAP_DEPT_ATTRIBUTE', 'umichPostalAddressData')
|
|
112
|
+
config.group_attribute = ENV.fetch('LDAP_GROUP_ATTRIBUTE', 'umichGroupEmail')
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**For Non-Rails Applications:**
|
|
117
|
+
|
|
118
|
+
Configure in your application startup:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
require 'ldap_lookup'
|
|
50
122
|
|
|
51
|
-
In your application create a file config/initializers/ldap_lookup.rb
|
|
52
|
-
<pre>
|
|
53
123
|
LdapLookup.configuration do |config|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
124
|
+
config.host = 'ldap.umich.edu'
|
|
125
|
+
config.base = 'dc=umich,dc=edu'
|
|
126
|
+
config.username = ENV['LDAP_USERNAME']
|
|
127
|
+
config.password = ENV['LDAP_PASSWORD']
|
|
128
|
+
config.encryption = :start_tls
|
|
59
129
|
end
|
|
60
|
-
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
#### Step 4: Set Environment Variables
|
|
133
|
+
|
|
134
|
+
**Never hardcode credentials in your code!** Use environment variables (Hatchbox, Heroku, etc.).
|
|
135
|
+
|
|
136
|
+
**Development with `.env.example` (recommended):**
|
|
137
|
+
1. Copy the template: `cp .env.example .env`
|
|
138
|
+
2. Update the values in `.env` for your environment.
|
|
139
|
+
3. Load the variables into your shell (example):
|
|
140
|
+
```bash
|
|
141
|
+
set -a
|
|
142
|
+
source .env
|
|
143
|
+
set +a
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Typical `.env` values:**
|
|
147
|
+
```bash
|
|
148
|
+
LDAP_USERNAME=your_service_account_uniqname
|
|
149
|
+
LDAP_PASSWORD=your_service_account_password
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Optional settings (override defaults as needed):**
|
|
153
|
+
```bash
|
|
154
|
+
LDAP_HOST=ldap.umich.edu
|
|
155
|
+
LDAP_PORT=389
|
|
156
|
+
LDAP_BASE=dc=umich,dc=edu
|
|
157
|
+
LDAP_ENCRYPTION=start_tls
|
|
158
|
+
LDAP_TLS_VERIFY=true
|
|
159
|
+
LDAP_CA_CERT=/path/to/ca-bundle.pem
|
|
160
|
+
LDAP_DEPT_ATTRIBUTE=umichPostalAddressData
|
|
161
|
+
LDAP_GROUP_ATTRIBUTE=umichGroupEmail
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Alternative: export in your shell**
|
|
165
|
+
```bash
|
|
166
|
+
export LDAP_USERNAME=your_service_account_uniqname
|
|
167
|
+
export LDAP_PASSWORD=your_service_account_password
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**For Production:**
|
|
171
|
+
- Use your platform's secrets management (Rails credentials, AWS Secrets Manager, etc.)
|
|
172
|
+
- Never commit credentials to version control
|
|
173
|
+
- Use service accounts, not personal accounts
|
|
174
|
+
|
|
175
|
+
#### Service Account Bind DN
|
|
176
|
+
|
|
177
|
+
If your service account uses a non-standard bind DN format, you can specify it:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
config.bind_dn = 'cn=my-service-account,ou=Service Accounts,dc=umich,dc=edu'
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
If `bind_dn` is not set, it defaults to: `uid=username,ou=People,base`
|
|
61
184
|
|
|
62
185
|
---
|
|
63
186
|
|
|
@@ -66,30 +189,63 @@ end
|
|
|
66
189
|
__uid_exist?:__ returns true if uid is in LDAP
|
|
67
190
|
```
|
|
68
191
|
LdapLookup.uid_exist?(uniqname)
|
|
192
|
+
response: true or false (boolean)
|
|
69
193
|
```
|
|
70
194
|
__get_simple_name:__ returns the Display Name
|
|
71
195
|
```
|
|
72
196
|
LdapLookup.get_simple_name(uniqname = nil)
|
|
197
|
+
response: name or "No #{attribute} found for #{uniqname}"
|
|
73
198
|
```
|
|
74
199
|
__get_dept:__ returns the users Department_name
|
|
75
200
|
```
|
|
76
201
|
LdapLookup.get_dept(uniqname = nil)
|
|
202
|
+
response: dept name or "No #{nested_attribute} found for #{uniqname}"
|
|
77
203
|
```
|
|
78
204
|
__get_email:__ returns the users email address
|
|
79
205
|
```
|
|
80
206
|
LdapLookup.get_email(uniqname = nil)
|
|
207
|
+
response: email or "No #{attribute} found for #{uniqname}"
|
|
81
208
|
```
|
|
82
209
|
__is_member_of_group?:__ returns true/false if uniqname is a member of the specified group
|
|
83
210
|
```
|
|
84
211
|
LdapLookup.is_member_of_group?(uid = nil, group_name = nil)
|
|
212
|
+
response: true or false (boolean)
|
|
85
213
|
```
|
|
86
214
|
__get_email_distribution_list:__ Returns the list of emails that are associated to a group.
|
|
87
215
|
```
|
|
88
216
|
LdapLookup.get_email_distribution_list(group_name = nil)
|
|
217
|
+
response: result_hash
|
|
89
218
|
```
|
|
90
219
|
__all_groups_for_user:__ Returns the list of groups that a user is a member of.
|
|
91
220
|
```
|
|
92
221
|
LdapLookup.all_groups_for_user(uniqname = nil)
|
|
222
|
+
response: result_array
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Running Tests
|
|
226
|
+
|
|
227
|
+
**Security Note:** Never put passwords in command line arguments. They are visible in process lists and shell history.
|
|
228
|
+
|
|
229
|
+
**Recommended: Use a .env file (most secure)**
|
|
230
|
+
1. Copy the example file: `cp .env.example .env`
|
|
231
|
+
2. Edit `.env` with your credentials:
|
|
232
|
+
```
|
|
233
|
+
LDAP_USERNAME=your_uniqname
|
|
234
|
+
LDAP_PASSWORD=your_password
|
|
235
|
+
```
|
|
236
|
+
3. Run tests: `bundle exec rspec`
|
|
237
|
+
|
|
238
|
+
**Alternative: Export environment variables**
|
|
239
|
+
```bash
|
|
240
|
+
export LDAP_USERNAME=your_uniqname
|
|
241
|
+
export LDAP_PASSWORD=your_password
|
|
242
|
+
bundle exec rspec
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
**Never do this (insecure):**
|
|
246
|
+
```bash
|
|
247
|
+
# ❌ DON'T: Password visible in process list
|
|
248
|
+
LDAP_PASSWORD=xxx bundle exec rspec
|
|
93
249
|
```
|
|
94
250
|
|
|
95
251
|
### Contributing
|
data/SETUP.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Quick Setup Guide for Gem Users
|
|
2
|
+
|
|
3
|
+
This guide will help you quickly set up `ldap_lookup` in your Rails application.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
1. **Get LDAP Credentials**
|
|
8
|
+
- For production: Request a service account from your IT department
|
|
9
|
+
- For development: You can temporarily use your personal uniqname/password
|
|
10
|
+
|
|
11
|
+
## Installation Steps
|
|
12
|
+
|
|
13
|
+
### 1. Add to Gemfile
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem 'ldap_lookup'
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Run `bundle install`
|
|
20
|
+
|
|
21
|
+
### 2. Create Initializer
|
|
22
|
+
|
|
23
|
+
Copy the example initializer:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# If you have the gem source
|
|
27
|
+
cp config/initializers/ldap_lookup.rb.example config/initializers/ldap_lookup.rb
|
|
28
|
+
|
|
29
|
+
# Or create it manually
|
|
30
|
+
touch config/initializers/ldap_lookup.rb
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 3. Configure Credentials
|
|
34
|
+
|
|
35
|
+
Edit `config/initializers/ldap_lookup.rb`:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
LdapLookup.configuration do |config|
|
|
39
|
+
config.host = ENV.fetch('LDAP_HOST', 'ldap.umich.edu')
|
|
40
|
+
config.base = ENV.fetch('LDAP_BASE', 'dc=umich,dc=edu')
|
|
41
|
+
# Leave unset to use anonymous binds (if your LDAP server allows it)
|
|
42
|
+
config.username = ENV['LDAP_USERNAME']
|
|
43
|
+
config.password = ENV['LDAP_PASSWORD']
|
|
44
|
+
config.encryption = :start_tls
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 4. Set Environment Variables
|
|
49
|
+
|
|
50
|
+
**Development (.env file):**
|
|
51
|
+
```bash
|
|
52
|
+
LDAP_USERNAME=your_service_account
|
|
53
|
+
LDAP_PASSWORD=your_password
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Production:**
|
|
57
|
+
Use your platform's secrets management:
|
|
58
|
+
- Rails: `config/credentials.yml.enc`
|
|
59
|
+
- Heroku: `heroku config:set LDAP_USERNAME=xxx`
|
|
60
|
+
- AWS: Secrets Manager
|
|
61
|
+
- etc.
|
|
62
|
+
|
|
63
|
+
### 5. Service Account Bind DN (if needed)
|
|
64
|
+
|
|
65
|
+
If your service account uses a custom bind DN format, add:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
config.bind_dn = 'cn=service-account,ou=Service Accounts,dc=umich,dc=edu'
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Your IT department will provide this if it's different from the default format.
|
|
72
|
+
|
|
73
|
+
## Usage
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
# Check if a user exists
|
|
77
|
+
LdapLookup.uid_exist?('uniqname')
|
|
78
|
+
|
|
79
|
+
# Get user's name
|
|
80
|
+
LdapLookup.get_simple_name('uniqname')
|
|
81
|
+
|
|
82
|
+
# Get user's email
|
|
83
|
+
LdapLookup.get_email('uniqname')
|
|
84
|
+
|
|
85
|
+
# Get user's department
|
|
86
|
+
LdapLookup.get_dept('uniqname')
|
|
87
|
+
|
|
88
|
+
# Check group membership
|
|
89
|
+
LdapLookup.is_member_of_group?('uniqname', 'group-name')
|
|
90
|
+
|
|
91
|
+
# Get all groups for a user
|
|
92
|
+
LdapLookup.all_groups_for_user('uniqname')
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Troubleshooting
|
|
96
|
+
|
|
97
|
+
**Anonymous bind fails**
|
|
98
|
+
- Your LDAP server may require authenticated binds
|
|
99
|
+
- Set `LDAP_USERNAME` and `LDAP_PASSWORD` (service account recommended)
|
|
100
|
+
- Verify credentials are correct
|
|
101
|
+
|
|
102
|
+
**Error: Connection timeout or SSL errors**
|
|
103
|
+
- Verify `config.host` is correct
|
|
104
|
+
- Try `config.encryption = :simple_tls` with `config.port = '636'` for LDAPS
|
|
105
|
+
- Check firewall rules allow outbound LDAP connections
|
|
106
|
+
|
|
107
|
+
**Service account not working**
|
|
108
|
+
- Verify the bind DN format with your IT department
|
|
109
|
+
- Set `config.bind_dn` if your service account uses a non-standard format
|
|
110
|
+
|
|
111
|
+
## Security Reminders
|
|
112
|
+
|
|
113
|
+
- ✅ Use environment variables for credentials
|
|
114
|
+
- ✅ Use service accounts in production
|
|
115
|
+
- ✅ Never commit credentials to version control
|
|
116
|
+
- ❌ Don't hardcode passwords in code
|
|
117
|
+
- ❌ Don't use personal accounts in production
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# LDAP Lookup Configuration
|
|
2
|
+
# Copy this file to config/initializers/ldap_lookup.rb and configure with your credentials
|
|
3
|
+
#
|
|
4
|
+
# IMPORTANT: Never commit credentials to version control!
|
|
5
|
+
# Use environment variables or a secrets management system.
|
|
6
|
+
|
|
7
|
+
LdapLookup.configuration do |config|
|
|
8
|
+
# LDAP Server Configuration
|
|
9
|
+
config.host = ENV.fetch('LDAP_HOST', 'ldap.umich.edu')
|
|
10
|
+
config.port = ENV.fetch('LDAP_PORT', '389')
|
|
11
|
+
config.base = ENV.fetch('LDAP_BASE', 'dc=umich,dc=edu')
|
|
12
|
+
|
|
13
|
+
# Authentication (optional for anonymous binds)
|
|
14
|
+
# Option 1: Use a service account (recommended for production)
|
|
15
|
+
# Request a service account from your IT department
|
|
16
|
+
# Leave unset to use anonymous binds (if your LDAP server allows it)
|
|
17
|
+
config.username = ENV['LDAP_USERNAME']
|
|
18
|
+
config.password = ENV['LDAP_PASSWORD']
|
|
19
|
+
|
|
20
|
+
# Option 2: If your service account uses a custom bind DN format, specify it:
|
|
21
|
+
# config.bind_dn = 'cn=service-account,ou=Service Accounts,dc=umich,dc=edu'
|
|
22
|
+
# (If bind_dn is not set, it defaults to: uid=username,ou=People,base)
|
|
23
|
+
|
|
24
|
+
# Encryption - REQUIRED (as of Jan 20, 2026)
|
|
25
|
+
# Use :start_tls for port 389 or :simple_tls for LDAPS on port 636
|
|
26
|
+
encryption_method = ENV.fetch('LDAP_ENCRYPTION', 'start_tls').to_sym
|
|
27
|
+
config.encryption = encryption_method
|
|
28
|
+
|
|
29
|
+
# Optional: Attribute Configuration
|
|
30
|
+
config.dept_attribute = ENV.fetch('LDAP_DEPT_ATTRIBUTE', 'umichPostalAddressData')
|
|
31
|
+
config.group_attribute = ENV.fetch('LDAP_GROUP_ATTRIBUTE', 'umichGroupEmail')
|
|
32
|
+
end
|
data/ldap_lookup.gemspec
CHANGED
|
@@ -9,8 +9,8 @@ Gem::Specification.new do |spec|
|
|
|
9
9
|
spec.authors = ["Rick Smoke"]
|
|
10
10
|
spec.email = ["rsmoke@umich.edu"]
|
|
11
11
|
|
|
12
|
-
spec.summary = %q{
|
|
13
|
-
spec.description = %q{This
|
|
12
|
+
spec.summary = %q{Authenticated LDAP lookup for MCommunity user attributes at University of Michigan.}
|
|
13
|
+
spec.description = %q{This gem provides authenticated LDAP lookups for user attributes in the MCommunity service at the University of Michigan. It supports encrypted connections (STARTTLS/LDAPS) and service accounts as required by UM IT Security (effective Jan 20, 2026). Can be easily modified for other LDAP server configurations.}
|
|
14
14
|
spec.homepage = "https://github.com/rsmoke/ldap_lookup.git"
|
|
15
15
|
spec.license = "MIT"
|
|
16
16
|
|
|
@@ -24,5 +24,6 @@ Gem::Specification.new do |spec|
|
|
|
24
24
|
spec.add_development_dependency "bundler", "~> 2.2.26"
|
|
25
25
|
spec.add_development_dependency "rake", "~> 13.0"
|
|
26
26
|
spec.add_development_dependency "rspec", "~> 3.7.0"
|
|
27
|
+
spec.add_development_dependency "dotenv", "~> 2.8"
|
|
27
28
|
spec.add_dependency 'net-ldap', '~> 0.18.0'
|
|
28
29
|
end
|
data/ldaptest.rb
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
|
|
3
|
+
# Load .env file if it exists (for local development)
|
|
4
|
+
begin
|
|
5
|
+
require 'dotenv/load'
|
|
6
|
+
rescue LoadError
|
|
7
|
+
# dotenv not available, will use environment variables or fallbacks
|
|
8
|
+
end
|
|
9
|
+
|
|
3
10
|
require_relative "lib/ldap_lookup"
|
|
4
11
|
|
|
5
12
|
class Ldaptest
|
|
@@ -7,10 +14,20 @@ class Ldaptest
|
|
|
7
14
|
|
|
8
15
|
############## CONFIGURATION BLOCK ###################
|
|
9
16
|
LdapLookup.configuration do |config|
|
|
10
|
-
config.host = "ldap.umich.edu"
|
|
11
|
-
config.
|
|
12
|
-
config.
|
|
13
|
-
|
|
17
|
+
config.host = ENV['LDAP_HOST'] || "ldap.umich.edu"
|
|
18
|
+
config.port = ENV['LDAP_PORT'] || "389"
|
|
19
|
+
config.base = ENV['LDAP_BASE'] || "dc=umich,dc=edu"
|
|
20
|
+
# Leave username/password unset for anonymous binds
|
|
21
|
+
config.username = ENV['LDAP_USERNAME']
|
|
22
|
+
config.password = ENV['LDAP_PASSWORD']
|
|
23
|
+
# Read encryption from ENV, default to start_tls
|
|
24
|
+
encryption_str = ENV['LDAP_ENCRYPTION'] || 'start_tls'
|
|
25
|
+
config.encryption = encryption_str.to_sym
|
|
26
|
+
config.dept_attribute = ENV['LDAP_DEPT_ATTRIBUTE'] || "umichPostalAddressData"
|
|
27
|
+
config.group_attribute = ENV['LDAP_GROUP_ATTRIBUTE'] || "umichGroupEmail"
|
|
28
|
+
# Enable LDAP debug logging in this test runner
|
|
29
|
+
debug_str = ENV['LDAP_DEBUG']
|
|
30
|
+
config.debug = debug_str ? debug_str.to_s.downcase == 'true' : true
|
|
14
31
|
end
|
|
15
32
|
#######################################################
|
|
16
33
|
|
|
@@ -32,7 +49,7 @@ class Ldaptest
|
|
|
32
49
|
end
|
|
33
50
|
|
|
34
51
|
def result_box(answer)
|
|
35
|
-
print "\e[2J\e[f"
|
|
52
|
+
# print "\e[2J\e[f"
|
|
36
53
|
2.times { puts " " }
|
|
37
54
|
puts "Your Results"
|
|
38
55
|
puts "======================================================"
|
|
@@ -65,6 +82,7 @@ class Ldaptest
|
|
|
65
82
|
puts "7: check if uid is member of a group"
|
|
66
83
|
puts "+++++++++++++++++++++++++"
|
|
67
84
|
puts "8: what time is it?"
|
|
85
|
+
puts "99: test LDAP connection (diagnostic)"
|
|
68
86
|
puts "0: exit"
|
|
69
87
|
puts ""
|
|
70
88
|
print "Enter a number: "
|
|
@@ -80,6 +98,7 @@ class Ldaptest
|
|
|
80
98
|
when 6 then result_box(LdapLookup.get_email_distribution_list(@group_uid))
|
|
81
99
|
when 7 then result_box(LdapLookup.is_member_of_group?(@uid, @group_uid))
|
|
82
100
|
when 8 then result_box(timestamp)
|
|
101
|
+
when 99 then result_box(LdapLookup.test_connection.inspect)
|
|
83
102
|
when 0 then puts "you chose exit!"
|
|
84
103
|
throw(:done)
|
|
85
104
|
else
|
data/lib/ldap_lookup/version.rb
CHANGED
data/lib/ldap_lookup.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require_relative 'helpers/configuration'
|
|
2
2
|
require 'net/ldap'
|
|
3
|
+
require 'openssl'
|
|
3
4
|
|
|
4
5
|
module LdapLookup
|
|
5
6
|
extend Configuration
|
|
@@ -9,114 +10,442 @@ module LdapLookup
|
|
|
9
10
|
define_setting :base
|
|
10
11
|
define_setting :dept_attribute
|
|
11
12
|
define_setting :group_attribute
|
|
13
|
+
define_setting :username
|
|
14
|
+
define_setting :password
|
|
15
|
+
define_setting :bind_dn # Optional: custom bind DN (for service accounts). If not set, uses uid=username,ou=People,base
|
|
16
|
+
define_setting :encryption, :start_tls # :start_tls or :simple_tls (LDAPS)
|
|
17
|
+
define_setting :debug, false
|
|
18
|
+
|
|
19
|
+
def self.debug_log(message)
|
|
20
|
+
return unless debug
|
|
21
|
+
|
|
22
|
+
puts "[LDAP DEBUG] #{message}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.perform_search(ldap, base: nil, filter:, attributes: nil, label: nil, options: {})
|
|
26
|
+
search_base = base || ldap.base
|
|
27
|
+
filter_str = filter.respond_to?(:to_s) ? filter.to_s : filter.inspect
|
|
28
|
+
attrs_list = attributes ? Array(attributes).map(&:to_s) : ['*']
|
|
29
|
+
label_prefix = label ? "#{label} " : ""
|
|
30
|
+
|
|
31
|
+
debug_log("#{label_prefix}search base=#{search_base} filter=#{filter_str} attrs=#{attrs_list.join(',')}")
|
|
32
|
+
|
|
33
|
+
params = { base: search_base, filter: filter }
|
|
34
|
+
params[:attributes] = attributes if attributes
|
|
35
|
+
params.merge!(options) if options && !options.empty?
|
|
36
|
+
|
|
37
|
+
results = ldap.search(params) || []
|
|
38
|
+
entry_count = results ? results.size : 0
|
|
39
|
+
returned_attrs = []
|
|
40
|
+
if results && !results.empty?
|
|
41
|
+
returned_attrs = results.first.attribute_names.map(&:to_s).sort
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
debug_log("#{label_prefix}search results count=#{entry_count} returned_attrs=#{returned_attrs.join(',')}")
|
|
45
|
+
|
|
46
|
+
results
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.operation_details(response)
|
|
50
|
+
details = {
|
|
51
|
+
code: response.code,
|
|
52
|
+
message: response.message
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if response.respond_to?(:error_message) && response.error_message && !response.error_message.empty?
|
|
56
|
+
details[:error_message] = response.error_message
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if response.respond_to?(:matched_dn) && response.matched_dn && !response.matched_dn.empty?
|
|
60
|
+
details[:matched_dn] = response.matched_dn
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if response.respond_to?(:referrals) && response.referrals && !response.referrals.empty?
|
|
64
|
+
details[:referrals] = response.referrals
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
details
|
|
68
|
+
end
|
|
12
69
|
|
|
13
70
|
def self.get_ldap_response(ldap)
|
|
14
71
|
response = ldap.get_operation_result
|
|
15
|
-
|
|
72
|
+
unless response.code.zero?
|
|
73
|
+
error_msg = "Response Code: #{response.code}, Message: #{response.message}"
|
|
74
|
+
if response.respond_to?(:error_message) && response.error_message && !response.error_message.empty?
|
|
75
|
+
error_msg += ", Diagnostic: #{response.error_message}"
|
|
76
|
+
end
|
|
77
|
+
if response.respond_to?(:matched_dn) && response.matched_dn && !response.matched_dn.empty?
|
|
78
|
+
error_msg += ", Matched DN: #{response.matched_dn}"
|
|
79
|
+
end
|
|
80
|
+
# Provide more helpful error messages for common codes
|
|
81
|
+
case response.code
|
|
82
|
+
when 19
|
|
83
|
+
error_msg += " (Constraint Violation - may require administrative access)"
|
|
84
|
+
when 49
|
|
85
|
+
error_msg += " (Invalid Credentials - check username/password)"
|
|
86
|
+
when 50
|
|
87
|
+
error_msg += " (Insufficient Access Rights)"
|
|
88
|
+
when 81
|
|
89
|
+
error_msg += " (Server Unavailable)"
|
|
90
|
+
end
|
|
91
|
+
raise error_msg
|
|
92
|
+
end
|
|
16
93
|
end
|
|
17
94
|
|
|
18
|
-
|
|
19
|
-
|
|
95
|
+
# Diagnostic method to test LDAP connection and bind
|
|
96
|
+
def self.test_connection
|
|
97
|
+
username_present = username && !username.to_s.strip.empty?
|
|
98
|
+
password_present = password && !password.to_s.strip.empty?
|
|
99
|
+
auth_dn = if bind_dn
|
|
100
|
+
bind_dn
|
|
101
|
+
elsif username_present
|
|
102
|
+
"uid=#{username},ou=People,#{base}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
search_base = username_present ? "ou=People,#{base}" : base
|
|
106
|
+
search_filter = username_present ? "(uid=#{username})" : "(objectClass=*)"
|
|
107
|
+
|
|
108
|
+
result = {
|
|
109
|
+
bind_dn: auth_dn,
|
|
110
|
+
username: username,
|
|
20
111
|
host: host,
|
|
21
112
|
port: port,
|
|
113
|
+
encryption: encryption,
|
|
22
114
|
base: base,
|
|
23
|
-
|
|
24
|
-
|
|
115
|
+
auth_mode: (username_present && password_present) ? 'authenticated' : 'anonymous'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
begin
|
|
119
|
+
ldap = ldap_connection
|
|
120
|
+
|
|
121
|
+
bind_response = nil
|
|
122
|
+
bind_exception = nil
|
|
123
|
+
|
|
124
|
+
# Try an explicit bind for diagnostics only (can return Code 19 even if searches work)
|
|
125
|
+
begin
|
|
126
|
+
bind_success = ldap.bind
|
|
127
|
+
bind_response = ldap.get_operation_result
|
|
128
|
+
rescue => e
|
|
129
|
+
bind_success = false
|
|
130
|
+
bind_exception = { class: e.class.name, message: e.message }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Net::LDAP binds automatically when performing operations (search, etc.)
|
|
134
|
+
# Explicit bind may fail with Code 19 on STARTTLS, but actual operations work fine
|
|
135
|
+
# Test by performing an actual search operation instead of explicit bind
|
|
136
|
+
|
|
137
|
+
# Try a simple search - this will trigger automatic bind
|
|
138
|
+
search_result = perform_search(
|
|
139
|
+
ldap,
|
|
140
|
+
base: search_base,
|
|
141
|
+
filter: search_filter,
|
|
142
|
+
attributes: ['uid', 'mail', 'displayName', 'cn', 'givenName', 'sn'],
|
|
143
|
+
label: "diagnostic",
|
|
144
|
+
options: { size: 1 }
|
|
145
|
+
)
|
|
146
|
+
search_response = ldap.get_operation_result
|
|
147
|
+
returned_attributes = []
|
|
148
|
+
if search_result && !search_result.empty?
|
|
149
|
+
entry = search_result.first
|
|
150
|
+
returned_attributes = entry.attribute_names.map(&:to_s).sort
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
if search_response.code.zero? || (search_response.code == 4 && (search_result && !search_result.empty?))
|
|
154
|
+
# Success! Bind worked (automatically during search)
|
|
155
|
+
result.merge!(
|
|
156
|
+
success: true,
|
|
157
|
+
bind_successful: true,
|
|
158
|
+
bind_attempted: true,
|
|
159
|
+
bind_result: bind_success,
|
|
160
|
+
bind_details: bind_response ? operation_details(bind_response) : nil,
|
|
161
|
+
bind_exception: bind_exception,
|
|
162
|
+
bind_code: 0,
|
|
163
|
+
bind_message: "Bind successful (via automatic bind during search)",
|
|
164
|
+
search_code: search_response.code,
|
|
165
|
+
search_message: search_response.message,
|
|
166
|
+
search_details: operation_details(search_response),
|
|
167
|
+
search_base: search_base,
|
|
168
|
+
search_filter: search_filter,
|
|
169
|
+
search_entry_count: search_result ? search_result.size : 0,
|
|
170
|
+
search_returned_attributes: returned_attributes,
|
|
171
|
+
note: "Explicit bind may show Code 19, but operations work correctly"
|
|
172
|
+
)
|
|
173
|
+
else
|
|
174
|
+
# Search failed - check if it's a bind issue or search issue
|
|
175
|
+
result.merge!(
|
|
176
|
+
success: false,
|
|
177
|
+
bind_successful: false,
|
|
178
|
+
bind_attempted: true,
|
|
179
|
+
bind_result: bind_success,
|
|
180
|
+
bind_details: bind_response ? operation_details(bind_response) : nil,
|
|
181
|
+
bind_exception: bind_exception,
|
|
182
|
+
search_code: search_response.code,
|
|
183
|
+
search_message: search_response.message,
|
|
184
|
+
search_details: operation_details(search_response),
|
|
185
|
+
search_base: search_base,
|
|
186
|
+
search_filter: search_filter,
|
|
187
|
+
search_entry_count: search_result ? search_result.size : 0,
|
|
188
|
+
search_returned_attributes: returned_attributes,
|
|
189
|
+
error: "Search failed: Code #{search_response.code}, #{search_response.message}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
case search_response.code
|
|
193
|
+
when 19
|
|
194
|
+
result[:suggestion] = "Constraint Violation. Your account may not be enabled for LDAP access or may need administrative access for this operation."
|
|
195
|
+
when 49
|
|
196
|
+
result[:suggestion] = "Invalid Credentials. Check your username and password."
|
|
197
|
+
when 50
|
|
198
|
+
result[:suggestion] = "Insufficient Access Rights. Your account may need LDAP access enabled."
|
|
199
|
+
when 4
|
|
200
|
+
result[:suggestion] = "Size Limit Exceeded. Try a more specific search base or ensure filters are indexed."
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
205
|
+
# Certificate or SSL/TLS connection error
|
|
206
|
+
result.merge!(
|
|
207
|
+
success: false,
|
|
208
|
+
error: "SSL/TLS Error: #{e.message}",
|
|
209
|
+
exception: e.class.name,
|
|
210
|
+
suggestion: "Certificate verification failed. Most systems trust InCommon certificates. If needed, download USERTrust RSA Certification Authority root certificate from ITS: SSL Server Certificates"
|
|
211
|
+
)
|
|
212
|
+
rescue => e
|
|
213
|
+
result.merge!(
|
|
214
|
+
success: false,
|
|
215
|
+
error: e.message,
|
|
216
|
+
exception: e.class.name
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
result
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def self.ldap_connection
|
|
224
|
+
connection_params = {
|
|
225
|
+
host: host,
|
|
226
|
+
port: port.to_i,
|
|
227
|
+
base: base
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# Configure encryption - REQUIRED for authenticated binds per UM documentation
|
|
231
|
+
# UM requires secure connection: TLS on port 389 (STARTTLS) or SSL on port 636 (LDAPS)
|
|
232
|
+
# Most operating systems already trust InCommon certificates per UM documentation
|
|
233
|
+
tls_verify = ENV.fetch('LDAP_TLS_VERIFY', 'true').to_s.downcase != 'false'
|
|
234
|
+
tls_options = {
|
|
235
|
+
verify_mode: tls_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
|
236
|
+
}
|
|
237
|
+
ca_cert_path = ENV['LDAP_CA_CERT']
|
|
238
|
+
tls_options[:ca_file] = ca_cert_path if ca_cert_path && !ca_cert_path.to_s.strip.empty?
|
|
239
|
+
|
|
240
|
+
if encryption == :start_tls
|
|
241
|
+
connection_params[:encryption] = {
|
|
242
|
+
method: :start_tls,
|
|
243
|
+
tls_options: tls_options
|
|
244
|
+
}
|
|
245
|
+
elsif encryption == :simple_tls
|
|
246
|
+
connection_params[:encryption] = {
|
|
247
|
+
method: :simple_tls,
|
|
248
|
+
tls_options: tls_options
|
|
249
|
+
}
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Configure authenticated bind (if username/password provided)
|
|
253
|
+
# Note: "simple" bind method = authenticated bind with username/password (not anonymous)
|
|
254
|
+
auth_username = username.to_s.strip
|
|
255
|
+
auth_password = password.to_s
|
|
256
|
+
if !auth_username.empty? && !auth_password.empty?
|
|
257
|
+
# Use custom bind_dn if provided (for service accounts), otherwise build standard DN
|
|
258
|
+
auth_bind_dn = bind_dn || "uid=#{auth_username},ou=People,#{base}"
|
|
259
|
+
connection_params[:auth] = {
|
|
260
|
+
method: :simple, # Simple bind = authenticated bind with username/password
|
|
261
|
+
username: auth_bind_dn,
|
|
262
|
+
password: auth_password
|
|
263
|
+
}
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
ldap = Net::LDAP.new(connection_params)
|
|
267
|
+
|
|
268
|
+
# For STARTTLS, ensure TLS is started before returning connection
|
|
269
|
+
# Net::LDAP should handle this automatically, but let's be explicit
|
|
270
|
+
if encryption == :start_tls
|
|
271
|
+
begin
|
|
272
|
+
# The bind will trigger STARTTLS automatically, but we can verify connection works
|
|
273
|
+
# by attempting a bind (which will fail if TLS isn't established)
|
|
274
|
+
rescue => e
|
|
275
|
+
raise "Failed to establish TLS connection: #{e.message}"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
ldap
|
|
25
280
|
end
|
|
26
281
|
|
|
27
|
-
def self.get_user_attribute(uniqname, attribute)
|
|
282
|
+
def self.get_user_attribute(uniqname, attribute, default_value = nil)
|
|
28
283
|
ldap = ldap_connection
|
|
29
284
|
search_param = uniqname
|
|
30
285
|
result_attrs = [attribute]
|
|
286
|
+
found_value = nil
|
|
31
287
|
|
|
32
288
|
search_filter = Net::LDAP::Filter.eq('uid', search_param)
|
|
33
289
|
|
|
34
|
-
|
|
290
|
+
perform_search(
|
|
291
|
+
ldap,
|
|
292
|
+
filter: search_filter,
|
|
293
|
+
attributes: result_attrs,
|
|
294
|
+
label: "get_user_attribute",
|
|
295
|
+
options: { size: 1 }
|
|
296
|
+
).each do |item|
|
|
35
297
|
value = item[attribute]&.first
|
|
36
|
-
|
|
298
|
+
if value
|
|
299
|
+
found_value = value
|
|
300
|
+
break
|
|
301
|
+
end
|
|
37
302
|
end
|
|
38
303
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
304
|
+
# Check response - Code 19 may occur even when data is found
|
|
305
|
+
response = ldap.get_operation_result
|
|
306
|
+
if (response.code == 19 || response.code == 4) && found_value.nil?
|
|
307
|
+
# Constraint violation and no data found - may need admin access
|
|
308
|
+
return default_value
|
|
309
|
+
elsif response.code != 0 && found_value.nil?
|
|
310
|
+
# Other error and no data found
|
|
311
|
+
raise "Response Code: #{response.code}, Message: #{response.message}"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Return found value or default
|
|
315
|
+
found_value || default_value
|
|
42
316
|
end
|
|
43
317
|
|
|
44
318
|
def self.get_nested_attribute(uniqname, nested_attribute)
|
|
45
319
|
ldap = ldap_connection
|
|
46
320
|
search_param = uniqname
|
|
47
321
|
# Specify the full nested attribute path using dot notation
|
|
48
|
-
|
|
49
|
-
|
|
322
|
+
attr_name = nested_attribute.split('.').first
|
|
323
|
+
# Try using the configured attribute name if available, otherwise use the provided name
|
|
324
|
+
search_attr = dept_attribute || attr_name
|
|
325
|
+
result_attrs = [search_attr]
|
|
326
|
+
found_value = nil
|
|
327
|
+
|
|
50
328
|
search_filter = Net::LDAP::Filter.eq('uid', search_param)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
329
|
+
|
|
330
|
+
perform_search(
|
|
331
|
+
ldap,
|
|
332
|
+
filter: search_filter,
|
|
333
|
+
attributes: result_attrs,
|
|
334
|
+
label: "get_nested_attribute",
|
|
335
|
+
options: { size: 1 }
|
|
336
|
+
).each do |item|
|
|
337
|
+
# Net::LDAP::Entry provides case-insensitive access, try the search attribute first
|
|
338
|
+
string1 = item[search_attr]&.first || item[attr_name]&.first
|
|
339
|
+
if string1
|
|
55
340
|
key_value_pairs = string1.split('}:{')
|
|
56
|
-
# Find the key-value pair for
|
|
57
|
-
target_pair = key_value_pairs.find { |pair| pair.include?("#{nested_attribute.split('.').last}=") }
|
|
341
|
+
# Find the key-value pair for the nested attribute
|
|
342
|
+
target_pair = key_value_pairs.find { |pair| pair.include?("#{nested_attribute.split('.').last}=") }
|
|
58
343
|
# Extract the target value
|
|
59
|
-
|
|
60
|
-
|
|
344
|
+
if target_pair
|
|
345
|
+
target_pair_value = target_pair.split('=').last
|
|
346
|
+
if target_pair_value
|
|
347
|
+
found_value = target_pair_value
|
|
348
|
+
break
|
|
349
|
+
end
|
|
350
|
+
end
|
|
61
351
|
end
|
|
62
352
|
end
|
|
63
|
-
"No #{nested_attribute} found for #{uniqname}"
|
|
64
353
|
|
|
65
|
-
|
|
66
|
-
|
|
354
|
+
# Check response - Code 19 may occur even when data is found
|
|
355
|
+
response = ldap.get_operation_result
|
|
356
|
+
if (response.code == 19 || response.code == 4) && found_value.nil?
|
|
357
|
+
# Constraint violation and no data found - may need admin access
|
|
358
|
+
return nil
|
|
359
|
+
elsif response.code != 0 && found_value.nil?
|
|
360
|
+
# Other error and no data found
|
|
361
|
+
raise "Response Code: #{response.code}, Message: #{response.message}"
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
found_value
|
|
67
365
|
end
|
|
68
366
|
|
|
69
367
|
# method to check if a uid exist in LDAP
|
|
70
368
|
def self.uid_exist?(uniqname)
|
|
71
369
|
ldap = ldap_connection
|
|
72
370
|
search_param = uniqname
|
|
371
|
+
found = false
|
|
73
372
|
|
|
74
373
|
search_filter = Net::LDAP::Filter.eq('uid', search_param)
|
|
75
374
|
|
|
76
|
-
ldap
|
|
77
|
-
|
|
375
|
+
perform_search(ldap, filter: search_filter, label: "uid_exist", options: { size: 1 }).each do |item|
|
|
376
|
+
if item['uid'].first == search_param
|
|
377
|
+
found = true
|
|
378
|
+
break
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Check response - Code 19 may occur even when user is found
|
|
383
|
+
response = ldap.get_operation_result
|
|
384
|
+
if (response.code == 19 || response.code == 4) && !found
|
|
385
|
+
# Constraint violation and user not found - may need admin access
|
|
386
|
+
return false
|
|
387
|
+
elsif response.code != 0 && !found
|
|
388
|
+
# Other error and user not found
|
|
389
|
+
raise "Response Code: #{response.code}, Message: #{response.message}"
|
|
78
390
|
end
|
|
79
391
|
|
|
80
|
-
|
|
81
|
-
ensure
|
|
82
|
-
get_ldap_response(ldap)
|
|
392
|
+
found
|
|
83
393
|
end
|
|
84
394
|
|
|
85
395
|
def self.get_simple_name(uniqname)
|
|
86
|
-
get_user_attribute(uniqname, 'displayname')
|
|
396
|
+
get_user_attribute(uniqname, 'displayname', 'not available')
|
|
87
397
|
end
|
|
88
398
|
|
|
89
399
|
def self.get_email(uniqname)
|
|
90
|
-
get_user_attribute(uniqname, 'mail')
|
|
400
|
+
get_user_attribute(uniqname, 'mail', nil)
|
|
91
401
|
end
|
|
92
402
|
|
|
93
403
|
def self.get_dept(uniqname)
|
|
94
|
-
get_nested_attribute(uniqname, 'umichpostaladdressdata.addr1')
|
|
404
|
+
dept = get_nested_attribute(uniqname, 'umichpostaladdressdata.addr1')
|
|
405
|
+
return dept if dept
|
|
406
|
+
|
|
407
|
+
# Fallback to raw attribute if nested parsing fails or attribute is restricted
|
|
408
|
+
raw_attr = dept_attribute || 'umichPostalAddressData'
|
|
409
|
+
get_user_attribute(uniqname, raw_attr, nil)
|
|
95
410
|
end
|
|
96
411
|
|
|
97
412
|
def self.is_member_of_group?(uid, group_name)
|
|
98
413
|
ldap = ldap_connection
|
|
99
414
|
search_param = group_name
|
|
100
415
|
result_attrs = ['member']
|
|
416
|
+
found = false
|
|
101
417
|
|
|
102
418
|
search_filter = Net::LDAP::Filter.join(
|
|
103
419
|
Net::LDAP::Filter.eq('cn', search_param),
|
|
104
420
|
Net::LDAP::Filter.eq('objectClass', 'group')
|
|
105
421
|
)
|
|
106
422
|
|
|
107
|
-
ldap
|
|
423
|
+
perform_search(ldap, filter: search_filter, attributes: result_attrs, label: "is_member_of_group").each do |item|
|
|
108
424
|
members = item['member']
|
|
109
|
-
|
|
425
|
+
if members && members.any? { |entry| entry.split(',').first.split('=')[1] == uid }
|
|
426
|
+
found = true
|
|
427
|
+
break
|
|
428
|
+
end
|
|
110
429
|
end
|
|
111
430
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
431
|
+
# Check response - Code 19 may occur for group operations (requires admin access)
|
|
432
|
+
response = ldap.get_operation_result
|
|
433
|
+
if response.code == 19
|
|
434
|
+
# Constraint violation - group operations may require admin access
|
|
435
|
+
# Return false if not found, true if found (even with Code 19)
|
|
436
|
+
return found
|
|
437
|
+
elsif response.code != 0 && !found
|
|
438
|
+
# Other error and not found
|
|
439
|
+
raise "Response Code: #{response.code}, Message: #{response.message}"
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
found
|
|
115
443
|
end
|
|
116
444
|
|
|
117
445
|
def self.get_email_distribution_list(group_name)
|
|
118
446
|
ldap = ldap_connection
|
|
119
447
|
result_hash = {}
|
|
448
|
+
found_data = false
|
|
120
449
|
|
|
121
450
|
search_param = group_name
|
|
122
451
|
result_attrs = %w[cn umichGroupEmail member]
|
|
@@ -126,16 +455,25 @@ module LdapLookup
|
|
|
126
455
|
Net::LDAP::Filter.eq('objectClass', 'group')
|
|
127
456
|
)
|
|
128
457
|
|
|
129
|
-
ldap
|
|
458
|
+
perform_search(ldap, filter: search_filter, attributes: result_attrs, label: "get_email_distribution_list").each do |item|
|
|
459
|
+
found_data = true
|
|
130
460
|
result_hash['group_name'] = item['cn']&.first
|
|
131
461
|
result_hash['group_email'] = item['umichGroupEmail']&.first
|
|
132
462
|
members = item['member']&.map { |individual| individual.split(',').first.split('=')[1] }
|
|
133
463
|
result_hash['members'] = members&.sort || []
|
|
134
464
|
end
|
|
135
465
|
|
|
466
|
+
# Check response - Code 19 may occur for group operations (requires admin access)
|
|
467
|
+
response = ldap.get_operation_result
|
|
468
|
+
if response.code == 19 && !found_data
|
|
469
|
+
# Constraint violation and no data found - group operations may require admin access
|
|
470
|
+
return {}
|
|
471
|
+
elsif response.code != 0 && !found_data
|
|
472
|
+
# Other error and no data found
|
|
473
|
+
raise "Response Code: #{response.code}, Message: #{response.message}"
|
|
474
|
+
end
|
|
475
|
+
|
|
136
476
|
result_hash
|
|
137
|
-
ensure
|
|
138
|
-
get_ldap_response(ldap)
|
|
139
477
|
end
|
|
140
478
|
|
|
141
479
|
def self.all_groups_for_user(uid)
|
|
@@ -144,12 +482,22 @@ module LdapLookup
|
|
|
144
482
|
|
|
145
483
|
result_attrs = ['dn']
|
|
146
484
|
|
|
147
|
-
|
|
485
|
+
# Use configured base instead of hardcoded dc=umich,dc=edu
|
|
486
|
+
member_dn = "uid=#{uid},ou=People,#{base}"
|
|
487
|
+
perform_search(ldap, filter: "member=#{member_dn}", attributes: result_attrs, label: "all_groups_for_user").each do |item|
|
|
148
488
|
item.each { |key, value| result_array << value.first.split('=')[1].split(',')[0] }
|
|
149
489
|
end
|
|
150
490
|
|
|
491
|
+
# Check response - may raise Constraint Violation for regular users
|
|
492
|
+
response = ldap.get_operation_result
|
|
493
|
+
if response.code == 19 # Constraint Violation
|
|
494
|
+
# Regular authenticated users may not have permission to search groups by member
|
|
495
|
+
# Return empty array instead of raising error
|
|
496
|
+
return []
|
|
497
|
+
elsif response.code != 0
|
|
498
|
+
raise "Response Code: #{response.code}, Message: #{response.message}"
|
|
499
|
+
end
|
|
500
|
+
|
|
151
501
|
result_array.sort
|
|
152
|
-
ensure
|
|
153
|
-
get_ldap_response(ldap)
|
|
154
502
|
end
|
|
155
|
-
end
|
|
503
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ldap_lookup
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 2.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rick Smoke
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: bundler
|
|
@@ -52,6 +51,20 @@ dependencies:
|
|
|
52
51
|
- - "~>"
|
|
53
52
|
- !ruby/object:Gem::Version
|
|
54
53
|
version: 3.7.0
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: dotenv
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '2.8'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '2.8'
|
|
55
68
|
- !ruby/object:Gem::Dependency
|
|
56
69
|
name: net-ldap
|
|
57
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -66,25 +79,31 @@ dependencies:
|
|
|
66
79
|
- - "~>"
|
|
67
80
|
- !ruby/object:Gem::Version
|
|
68
81
|
version: 0.18.0
|
|
69
|
-
description: This
|
|
70
|
-
service
|
|
71
|
-
|
|
82
|
+
description: This gem provides authenticated LDAP lookups for user attributes in the
|
|
83
|
+
MCommunity service at the University of Michigan. It supports encrypted connections
|
|
84
|
+
(STARTTLS/LDAPS) and service accounts as required by UM IT Security (effective Jan
|
|
85
|
+
20, 2026). Can be easily modified for other LDAP server configurations.
|
|
72
86
|
email:
|
|
73
87
|
- rsmoke@umich.edu
|
|
74
88
|
executables: []
|
|
75
89
|
extensions: []
|
|
76
90
|
extra_rdoc_files: []
|
|
77
91
|
files:
|
|
92
|
+
- ".env.example"
|
|
78
93
|
- ".github/dependabot.yml"
|
|
79
94
|
- ".gitignore"
|
|
95
|
+
- ".rspec"
|
|
96
|
+
- ".rspec_status"
|
|
80
97
|
- CODE_OF_CONDUCT.md
|
|
81
98
|
- Gemfile
|
|
82
99
|
- Gemfile.lock
|
|
83
100
|
- LICENSE.txt
|
|
84
101
|
- README.md
|
|
85
102
|
- Rakefile
|
|
103
|
+
- SETUP.md
|
|
86
104
|
- bin/console
|
|
87
105
|
- bin/setup
|
|
106
|
+
- config/initializers/ldap_lookup.rb.example
|
|
88
107
|
- ldap_lookup.gemspec
|
|
89
108
|
- ldaptest.rb
|
|
90
109
|
- lib/helpers/configuration.rb
|
|
@@ -94,7 +113,6 @@ homepage: https://github.com/rsmoke/ldap_lookup.git
|
|
|
94
113
|
licenses:
|
|
95
114
|
- MIT
|
|
96
115
|
metadata: {}
|
|
97
|
-
post_install_message:
|
|
98
116
|
rdoc_options: []
|
|
99
117
|
require_paths:
|
|
100
118
|
- lib
|
|
@@ -109,8 +127,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
109
127
|
- !ruby/object:Gem::Version
|
|
110
128
|
version: '0'
|
|
111
129
|
requirements: []
|
|
112
|
-
rubygems_version: 3.
|
|
113
|
-
signing_key:
|
|
130
|
+
rubygems_version: 3.6.9
|
|
114
131
|
specification_version: 4
|
|
115
|
-
summary:
|
|
132
|
+
summary: Authenticated LDAP lookup for MCommunity user attributes at University of
|
|
133
|
+
Michigan.
|
|
116
134
|
test_files: []
|