omniauth-ldap 2.3.1 → 2.3.3
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
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +48 -1
- data/CONTRIBUTING.md +6 -3
- data/FUNDING.md +7 -10
- data/LICENSE.txt +1 -0
- data/README.md +217 -17
- data/lib/omniauth/strategies/ldap.rb +223 -29
- data/lib/omniauth-ldap/adaptor.rb +210 -16
- data/lib/omniauth-ldap/version.rb +10 -1
- data/lib/omniauth-ldap.rb +8 -0
- data/sig/omniauth/ldap/adaptor.rbs +24 -6
- data/sig/omniauth/strategies/ldap.rbs +6 -3
- data/sig/omniauth-ldap.rbs +5 -0
- data/sig/rbs/net-ldap.rbs +17 -1
- data/sig/rbs/net-ntlm.rbs +2 -1
- data.tar.gz.sig +0 -0
- metadata +6 -34
- metadata.gz.sig +0 -0
|
@@ -1,15 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "omniauth"
|
|
2
4
|
require "omniauth/version"
|
|
3
5
|
|
|
6
|
+
# OmniAuth strategies namespace.
|
|
7
|
+
#
|
|
8
|
+
# This file implements an LDAP authentication strategy for OmniAuth.
|
|
9
|
+
# It provides both an interactive request phase (login form) and a
|
|
10
|
+
# callback phase which binds to an LDAP directory to authenticate the
|
|
11
|
+
# user or performs a lookup for header-based SSO.
|
|
12
|
+
#
|
|
13
|
+
# The strategy exposes a number of options (see `option` calls below)
|
|
14
|
+
# that control LDAP connection, mapping of LDAP attributes to the
|
|
15
|
+
# OmniAuth `info` hash, header-based SSO behavior, and SSL/timeouts.
|
|
16
|
+
#
|
|
17
|
+
# @example Minimal Rack mounting
|
|
18
|
+
# use OmniAuth::Builder do
|
|
19
|
+
# provider :ldap, {
|
|
20
|
+
# host: 'ldap.example.com',
|
|
21
|
+
# base: 'dc=example,dc=com'
|
|
22
|
+
# }
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
4
25
|
module OmniAuth
|
|
5
26
|
module Strategies
|
|
27
|
+
# LDAP OmniAuth strategy
|
|
28
|
+
#
|
|
29
|
+
# This class implements the OmniAuth::Strategy interface and performs
|
|
30
|
+
# LDAP authentication using an `Adaptor` object. It supports three
|
|
31
|
+
# primary flows:
|
|
32
|
+
#
|
|
33
|
+
# - Interactive login form (request_phase) where users POST username/password
|
|
34
|
+
# - Callback binding where the strategy attempts to bind as the user
|
|
35
|
+
# - Header-based SSO (trusted upstream) where a header identifies the user
|
|
36
|
+
#
|
|
37
|
+
# The mapping from LDAP attributes to resulting `info` fields is
|
|
38
|
+
# configurable via the `:mapping` option. See `map_user` for the
|
|
39
|
+
# mapping algorithm.
|
|
40
|
+
#
|
|
41
|
+
# @see OmniAuth::Strategy
|
|
6
42
|
class LDAP
|
|
43
|
+
# Whether the loaded OmniAuth version is >= 2.0.0; used to set default request methods.
|
|
44
|
+
# @return [Boolean]
|
|
7
45
|
OMNIAUTH_GTE_V2 = Gem::Version.new(OmniAuth::VERSION) >= Gem::Version.new("2.0.0")
|
|
46
|
+
|
|
8
47
|
include OmniAuth::Strategy
|
|
9
48
|
|
|
49
|
+
# Raised when credentials are invalid or the user cannot be authenticated.
|
|
50
|
+
# @example
|
|
51
|
+
# raise InvalidCredentialsError, 'Invalid credentials'
|
|
10
52
|
InvalidCredentialsError = Class.new(StandardError)
|
|
11
53
|
|
|
12
|
-
|
|
54
|
+
# Default mapping for converting LDAP attributes to OmniAuth `info` keys.
|
|
55
|
+
# Keys are the resulting `info` hash keys (strings). Values may be:
|
|
56
|
+
# - String: single LDAP attribute name
|
|
57
|
+
# - Array: list of attribute names in priority order
|
|
58
|
+
# - Hash: pattern mapping where pattern keys contain %<n> placeholders
|
|
59
|
+
# that are substituted from a list of possible attribute names
|
|
60
|
+
#
|
|
61
|
+
# @return [Hash<String, String|Array|Hash>]
|
|
62
|
+
option :mapping, {
|
|
13
63
|
"name" => "cn",
|
|
14
64
|
"first_name" => "givenName",
|
|
15
65
|
"last_name" => "sn",
|
|
@@ -23,8 +73,12 @@ module OmniAuth
|
|
|
23
73
|
"url" => ["wwwhomepage"],
|
|
24
74
|
"image" => "jpegPhoto",
|
|
25
75
|
"description" => "description",
|
|
26
|
-
}
|
|
27
|
-
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Default title shown on the login form.
|
|
79
|
+
# @return [String]
|
|
80
|
+
option :title, "LDAP Authentication"
|
|
81
|
+
|
|
28
82
|
# For OmniAuth >= 2.0 the default allowed request method is POST only.
|
|
29
83
|
# Ensure the strategy follows that default so GET /auth/:provider returns 404 as expected in tests.
|
|
30
84
|
if OMNIAUTH_GTE_V2
|
|
@@ -32,6 +86,8 @@ module OmniAuth
|
|
|
32
86
|
else
|
|
33
87
|
option(:request_methods, [:get, :post])
|
|
34
88
|
end
|
|
89
|
+
|
|
90
|
+
# Default LDAP connection options / behavior
|
|
35
91
|
option :port, 389
|
|
36
92
|
option :method, :plain
|
|
37
93
|
option :disable_verify_certificates, false
|
|
@@ -39,6 +95,7 @@ module OmniAuth
|
|
|
39
95
|
option :ssl_version, nil # use OpenSSL default if nil
|
|
40
96
|
option :uid, "sAMAccountName"
|
|
41
97
|
option :name_proc, lambda { |n| n }
|
|
98
|
+
|
|
42
99
|
# Trusted header SSO support (disabled by default)
|
|
43
100
|
# :header_auth - when true and the header is present, the strategy trusts the upstream gateway
|
|
44
101
|
# and searches the directory for the user without requiring a user password.
|
|
@@ -47,6 +104,19 @@ module OmniAuth
|
|
|
47
104
|
option :header_auth, false
|
|
48
105
|
option :header_name, "REMOTE_USER"
|
|
49
106
|
|
|
107
|
+
# Optional timeouts (forwarded to Net::LDAP when supported)
|
|
108
|
+
option :connect_timeout, nil
|
|
109
|
+
option :read_timeout, nil
|
|
110
|
+
|
|
111
|
+
# Request phase: Render the login form or redirect to callback for header-auth or direct POSTed credentials
|
|
112
|
+
#
|
|
113
|
+
# This will behave differently depending on OmniAuth version and request method:
|
|
114
|
+
# - For OmniAuth >= 2.0 a GET to /auth/:provider should return 404 (so we return a 404 for GET requests).
|
|
115
|
+
# - If header-based SSO is enabled and a trusted header is present we immediately redirect to the callback.
|
|
116
|
+
# - If credentials are POSTed directly to /auth/:provider we redirect to the callback so the test helpers
|
|
117
|
+
# that populate `env['omniauth.auth']` can operate on the callback request.
|
|
118
|
+
#
|
|
119
|
+
# @return [Array] A Rack response triple from the login form or redirect.
|
|
50
120
|
def request_phase
|
|
51
121
|
# OmniAuth >= 2.0 expects the request phase to be POST-only for /auth/:provider.
|
|
52
122
|
# Some test environments (and OmniAuth itself) enforce this by returning 404 on GET.
|
|
@@ -57,24 +127,37 @@ module OmniAuth
|
|
|
57
127
|
# Fast-path: if a trusted identity header is present, skip the login form
|
|
58
128
|
# and jump to the callback where we will complete using directory lookup.
|
|
59
129
|
if header_username
|
|
60
|
-
return Rack::Response.new([], 302, "Location" =>
|
|
130
|
+
return Rack::Response.new([], 302, "Location" => callback_url).finish
|
|
61
131
|
end
|
|
62
132
|
|
|
63
133
|
# If credentials were POSTed directly to /auth/:provider, redirect to the callback path.
|
|
64
134
|
# This mirrors the behavior of many OmniAuth providers and allows test helpers (like
|
|
65
135
|
# OmniAuth::Test::PhonySession) to populate `env['omniauth.auth']` on the callback request.
|
|
66
|
-
if request.post? &&
|
|
67
|
-
return Rack::Response.new([], 302, "Location" =>
|
|
136
|
+
if request.post? && request_data["username"].to_s != "" && request_data["password"].to_s != ""
|
|
137
|
+
return Rack::Response.new([], 302, "Location" => callback_url).finish
|
|
68
138
|
end
|
|
69
139
|
|
|
70
140
|
OmniAuth::LDAP::Adaptor.validate(@options)
|
|
71
|
-
f = OmniAuth::Form.new(title: options[:title] || "LDAP Authentication", url:
|
|
141
|
+
f = OmniAuth::Form.new(title: options[:title] || "LDAP Authentication", url: callback_url)
|
|
72
142
|
f.text_field("Login", "username")
|
|
73
143
|
f.password_field("Password", "password")
|
|
74
144
|
f.button("Sign In")
|
|
75
145
|
f.to_response
|
|
76
146
|
end
|
|
77
147
|
|
|
148
|
+
# Callback phase: Authenticate user or perform header-based lookup
|
|
149
|
+
#
|
|
150
|
+
# This method executes on the callback URL and implements the main
|
|
151
|
+
# authentication logic. There are two primary paths:
|
|
152
|
+
#
|
|
153
|
+
# - Header-based lookup: when `options[:header_auth]` is enabled and a header value is present,
|
|
154
|
+
# we perform a read-only directory lookup for the user and, if found, map attributes and finish.
|
|
155
|
+
# - Password bind: when username/password are provided we attempt a bind as the user using the adaptor.
|
|
156
|
+
#
|
|
157
|
+
# Errors raised by the LDAP adaptor are captured and turned into OmniAuth failures.
|
|
158
|
+
#
|
|
159
|
+
# @raise [InvalidCredentialsError] when credentials are invalid
|
|
160
|
+
# @return [Object] result of calling `super` from the OmniAuth::Strategy chain
|
|
78
161
|
def callback_phase
|
|
79
162
|
@adaptor = OmniAuth::LDAP::Adaptor.new(@options)
|
|
80
163
|
|
|
@@ -88,7 +171,7 @@ module OmniAuth
|
|
|
88
171
|
return fail!(:invalid_credentials, InvalidCredentialsError.new("User not found for header #{hu}"))
|
|
89
172
|
end
|
|
90
173
|
@ldap_user_info = entry
|
|
91
|
-
@user_info = self.class.map_user(
|
|
174
|
+
@user_info = self.class.map_user(@options[:mapping], @ldap_user_info)
|
|
92
175
|
return super
|
|
93
176
|
rescue => e
|
|
94
177
|
return fail!(:ldap_error, e)
|
|
@@ -97,39 +180,74 @@ module OmniAuth
|
|
|
97
180
|
|
|
98
181
|
return fail!(:missing_credentials) if missing_credentials?
|
|
99
182
|
begin
|
|
100
|
-
@ldap_user_info = @adaptor.bind_as(filter: filter(@adaptor), size: 1, password:
|
|
183
|
+
@ldap_user_info = @adaptor.bind_as(filter: filter(@adaptor), size: 1, password: request_data["password"])
|
|
101
184
|
|
|
102
185
|
unless @ldap_user_info
|
|
103
|
-
|
|
186
|
+
# Attach password policy info to env if available (best-effort)
|
|
187
|
+
attach_password_policy_env(@adaptor)
|
|
188
|
+
return fail!(:invalid_credentials, InvalidCredentialsError.new("Invalid credentials for #{request_data["username"]}"))
|
|
104
189
|
end
|
|
105
190
|
|
|
106
|
-
|
|
191
|
+
# Optionally attach policy info even on success (e.g., timeBeforeExpiration)
|
|
192
|
+
attach_password_policy_env(@adaptor)
|
|
193
|
+
|
|
194
|
+
@user_info = self.class.map_user(@options[:mapping], @ldap_user_info)
|
|
107
195
|
super
|
|
108
196
|
rescue => e
|
|
109
197
|
fail!(:ldap_error, e)
|
|
110
198
|
end
|
|
111
199
|
end
|
|
112
200
|
|
|
201
|
+
# Build an LDAP filter for searching/binding the user.
|
|
202
|
+
#
|
|
203
|
+
# If the adaptor has a custom `filter` option set it will be used (with
|
|
204
|
+
# interpolation of `%{username}`). Otherwise a simple equality filter for
|
|
205
|
+
# the configured uid attribute is used.
|
|
206
|
+
#
|
|
207
|
+
# @param adaptor [OmniAuth::LDAP::Adaptor] the adaptor used to build connection/filters
|
|
208
|
+
# @param username_override [String, nil] optional username to build the filter for (defaults to request username)
|
|
209
|
+
# @return [Net::LDAP::Filter] the constructed filter object
|
|
113
210
|
def filter(adaptor, username_override = nil)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
Net::LDAP::Filter.
|
|
211
|
+
flt = adaptor.filter
|
|
212
|
+
if flt && !flt.to_s.empty?
|
|
213
|
+
username = Net::LDAP::Filter.escape(@options[:name_proc].call(username_override || request_data["username"]))
|
|
214
|
+
Net::LDAP::Filter.construct(flt % {username: username})
|
|
117
215
|
else
|
|
118
|
-
Net::LDAP::Filter.equals(adaptor.uid, @options[:name_proc].call(username_override ||
|
|
216
|
+
Net::LDAP::Filter.equals(adaptor.uid, @options[:name_proc].call(username_override || request_data["username"]))
|
|
119
217
|
end
|
|
120
218
|
end
|
|
121
219
|
|
|
122
|
-
uid
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
220
|
+
# The uid exposed to OmniAuth consumers.
|
|
221
|
+
#
|
|
222
|
+
# This block-based DSL is part of OmniAuth::Strategy; document the value
|
|
223
|
+
# returned by the block.
|
|
224
|
+
#
|
|
225
|
+
# @return [String] the user's uid as determined from the mapped info
|
|
226
|
+
uid { @user_info["uid"] }
|
|
227
|
+
|
|
228
|
+
# The `info` hash returned to OmniAuth consumers. Usually contains name, email, etc.
|
|
229
|
+
# @return [Hash<String, Object>]
|
|
230
|
+
info { @user_info }
|
|
231
|
+
|
|
232
|
+
# Extra information exposed under `extra[:raw_info]` containing the raw LDAP entry.
|
|
233
|
+
# @return [Hash{Symbol => Object}]
|
|
234
|
+
extra { {raw_info: @ldap_user_info} }
|
|
131
235
|
|
|
132
236
|
class << self
|
|
237
|
+
# Map LDAP attributes from the directory entry into a simple Hash used
|
|
238
|
+
# for the OmniAuth `info` hash according to the provided `mapper`.
|
|
239
|
+
#
|
|
240
|
+
# The mapper supports three types of values:
|
|
241
|
+
# - String: a single attribute name. The method will call the attribute
|
|
242
|
+
# reader (downcased symbol) on the `object` and take the first value.
|
|
243
|
+
# - Array: iterate values and pick the first attribute that exists on the object.
|
|
244
|
+
# - Hash: a mapping of a pattern string to an array of attribute-name lists
|
|
245
|
+
# where each `%<n>` placeholder in the pattern will be substituted by the
|
|
246
|
+
# first available attribute from the corresponding list.
|
|
247
|
+
#
|
|
248
|
+
# @param mapper [Hash] mapping configuration (see option :mapping)
|
|
249
|
+
# @param object [#respond_to?, #[]] directory entry (commonly a Net::LDAP::Entry or similar)
|
|
250
|
+
# @return [Hash<String, Object>] the mapped user info hash
|
|
133
251
|
def map_user(mapper, object)
|
|
134
252
|
user = {}
|
|
135
253
|
mapper.each do |key, value|
|
|
@@ -168,16 +286,38 @@ module OmniAuth
|
|
|
168
286
|
|
|
169
287
|
protected
|
|
170
288
|
|
|
289
|
+
# Validate that the incoming request method is allowed.
|
|
290
|
+
#
|
|
291
|
+
# For OmniAuth >= 2.0 the default is POST only. This method checks the
|
|
292
|
+
# Rack env REQUEST_METHOD directly so tests and environments that stub
|
|
293
|
+
# request.HTTP_METHOD are handled deterministically.
|
|
294
|
+
#
|
|
295
|
+
# @return [Boolean] true when the request method is POST
|
|
171
296
|
def valid_request_method?
|
|
172
297
|
request.env["REQUEST_METHOD"] == "POST"
|
|
173
298
|
end
|
|
174
299
|
|
|
300
|
+
# Determine if the request is missing required credentials.
|
|
301
|
+
#
|
|
302
|
+
# @return [Boolean] true when username or password are nil/empty
|
|
175
303
|
def missing_credentials?
|
|
176
|
-
|
|
177
|
-
end
|
|
304
|
+
request_data["username"].nil? || request_data["username"].empty? || request_data["password"].nil? || request_data["password"].empty?
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Extract request parameters in a way compatible with Rails/Rack.
|
|
308
|
+
#
|
|
309
|
+
# @return [Hash] parameters hash containing at least "username" and "password" when provided
|
|
310
|
+
def request_data
|
|
311
|
+
@env["action_dispatch.request.request_parameters"] || request.params
|
|
312
|
+
end
|
|
178
313
|
|
|
179
314
|
# Extract a normalized username from a trusted header when enabled.
|
|
180
315
|
# Returns nil when not configured or not present.
|
|
316
|
+
#
|
|
317
|
+
# The method will attempt the raw env key (e.g. "REMOTE_USER") and the Rack
|
|
318
|
+
# HTTP_ variant (e.g. "HTTP_REMOTE_USER" or "HTTP_X_REMOTE_USER").
|
|
319
|
+
#
|
|
320
|
+
# @return [String, nil] normalized username or nil if not present
|
|
181
321
|
def header_username
|
|
182
322
|
return unless options[:header_auth]
|
|
183
323
|
|
|
@@ -191,15 +331,69 @@ module OmniAuth
|
|
|
191
331
|
|
|
192
332
|
# Perform a directory lookup for the given username using the strategy configuration
|
|
193
333
|
# (bind_dn/password or anonymous). Does not attempt to bind as the user.
|
|
334
|
+
#
|
|
335
|
+
# @param adaptor [OmniAuth::LDAP::Adaptor] initialized adaptor
|
|
336
|
+
# @param username [String] username to look up
|
|
337
|
+
# @return [Object, nil] first directory entry found or nil
|
|
194
338
|
def directory_lookup(adaptor, username)
|
|
195
339
|
entry = nil
|
|
196
|
-
|
|
340
|
+
search_filter = filter(adaptor, username)
|
|
197
341
|
adaptor.connection.open do |conn|
|
|
198
|
-
rs = conn.search(filter:
|
|
199
|
-
entry = rs
|
|
342
|
+
rs = conn.search(filter: search_filter, size: 1)
|
|
343
|
+
entry = rs && rs.first
|
|
200
344
|
end
|
|
201
345
|
entry
|
|
202
346
|
end
|
|
347
|
+
|
|
348
|
+
# If the adaptor captured a Password Policy response control, expose a minimal, stable hash
|
|
349
|
+
# in the Rack env for applications to inspect.
|
|
350
|
+
#
|
|
351
|
+
# The structure is available at `request.env['omniauth.ldap.password_policy']`.
|
|
352
|
+
#
|
|
353
|
+
# @param adaptor [OmniAuth::LDAP::Adaptor]
|
|
354
|
+
# @return [void]
|
|
355
|
+
def attach_password_policy_env(adaptor)
|
|
356
|
+
return unless adaptor.respond_to?(:password_policy) && adaptor.password_policy
|
|
357
|
+
ctrl = adaptor.respond_to?(:last_password_policy_response) ? adaptor.last_password_policy_response : nil
|
|
358
|
+
op = adaptor.respond_to?(:last_operation_result) ? adaptor.last_operation_result : nil
|
|
359
|
+
return unless ctrl || op
|
|
360
|
+
|
|
361
|
+
request.env["omniauth.ldap.password_policy"] = extract_password_policy(ctrl, op)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Best-effort extraction across net-ldap versions; if fields are not available, returns a raw payload.
|
|
365
|
+
#
|
|
366
|
+
# @param control [Object, nil] the password policy response control if available
|
|
367
|
+
# @param operation [Object, nil] the last operation result if available
|
|
368
|
+
# @return [Hash] normalized password policy info with keys :raw, :error, :time_before_expiration, :grace_authns_remaining, :oid, :operation
|
|
369
|
+
def extract_password_policy(control, operation)
|
|
370
|
+
data = {raw: control}
|
|
371
|
+
if control
|
|
372
|
+
# Prefer named readers if present
|
|
373
|
+
if control.respond_to?(:error)
|
|
374
|
+
data[:error] = control.public_send(:error)
|
|
375
|
+
elsif control.respond_to?(:ppolicy_error)
|
|
376
|
+
data[:error] = control.public_send(:ppolicy_error)
|
|
377
|
+
end
|
|
378
|
+
if control.respond_to?(:time_before_expiration)
|
|
379
|
+
data[:time_before_expiration] = control.public_send(:time_before_expiration)
|
|
380
|
+
end
|
|
381
|
+
if control.respond_to?(:grace_authns_remaining)
|
|
382
|
+
data[:grace_authns_remaining] = control.public_send(:grace_authns_remaining)
|
|
383
|
+
elsif control.respond_to?(:grace_logins_remaining)
|
|
384
|
+
data[:grace_authns_remaining] = control.public_send(:grace_logins_remaining)
|
|
385
|
+
end
|
|
386
|
+
if control.respond_to?(:oid)
|
|
387
|
+
data[:oid] = control.public_send(:oid)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
if operation
|
|
391
|
+
code = operation.respond_to?(:code) ? operation.code : nil
|
|
392
|
+
message = operation.respond_to?(:message) ? operation.message : nil
|
|
393
|
+
data[:operation] = {code: code, message: message}
|
|
394
|
+
end
|
|
395
|
+
data
|
|
396
|
+
end
|
|
203
397
|
end
|
|
204
398
|
end
|
|
205
399
|
end
|