Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
freebsd
GitHub Repository: freebsd/phabricator
Path: blob/master/src/applications/auth/adapter/PhutilLDAPAuthAdapter.php
12256 views
1
<?php
2
3
/**
4
* Retrieve identify information from LDAP accounts.
5
*/
6
final class PhutilLDAPAuthAdapter extends PhutilAuthAdapter {
7
8
private $hostname;
9
private $port = 389;
10
11
private $baseDistinguishedName;
12
private $searchAttributes = array();
13
private $usernameAttribute;
14
private $realNameAttributes = array();
15
private $ldapVersion = 3;
16
private $ldapReferrals;
17
private $ldapStartTLS;
18
private $anonymousUsername;
19
private $anonymousPassword;
20
private $activeDirectoryDomain;
21
private $alwaysSearch;
22
23
private $loginUsername;
24
private $loginPassword;
25
26
private $ldapUserData;
27
private $ldapConnection;
28
29
public function getAdapterType() {
30
return 'ldap';
31
}
32
33
public function setHostname($host) {
34
$this->hostname = $host;
35
return $this;
36
}
37
38
public function setPort($port) {
39
$this->port = $port;
40
return $this;
41
}
42
43
public function getAdapterDomain() {
44
return 'self';
45
}
46
47
public function setBaseDistinguishedName($base_distinguished_name) {
48
$this->baseDistinguishedName = $base_distinguished_name;
49
return $this;
50
}
51
52
public function setSearchAttributes(array $search_attributes) {
53
$this->searchAttributes = $search_attributes;
54
return $this;
55
}
56
57
public function setUsernameAttribute($username_attribute) {
58
$this->usernameAttribute = $username_attribute;
59
return $this;
60
}
61
62
public function setRealNameAttributes(array $attributes) {
63
$this->realNameAttributes = $attributes;
64
return $this;
65
}
66
67
public function setLDAPVersion($ldap_version) {
68
$this->ldapVersion = $ldap_version;
69
return $this;
70
}
71
72
public function setLDAPReferrals($ldap_referrals) {
73
$this->ldapReferrals = $ldap_referrals;
74
return $this;
75
}
76
77
public function setLDAPStartTLS($ldap_start_tls) {
78
$this->ldapStartTLS = $ldap_start_tls;
79
return $this;
80
}
81
82
public function setAnonymousUsername($anonymous_username) {
83
$this->anonymousUsername = $anonymous_username;
84
return $this;
85
}
86
87
public function setAnonymousPassword(
88
PhutilOpaqueEnvelope $anonymous_password) {
89
$this->anonymousPassword = $anonymous_password;
90
return $this;
91
}
92
93
public function setLoginUsername($login_username) {
94
$this->loginUsername = $login_username;
95
return $this;
96
}
97
98
public function setLoginPassword(PhutilOpaqueEnvelope $login_password) {
99
$this->loginPassword = $login_password;
100
return $this;
101
}
102
103
public function setActiveDirectoryDomain($domain) {
104
$this->activeDirectoryDomain = $domain;
105
return $this;
106
}
107
108
public function setAlwaysSearch($always_search) {
109
$this->alwaysSearch = $always_search;
110
return $this;
111
}
112
113
public function getAccountID() {
114
return $this->readLDAPRecordAccountID($this->getLDAPUserData());
115
}
116
117
public function getAccountName() {
118
return $this->readLDAPRecordAccountName($this->getLDAPUserData());
119
}
120
121
public function getAccountRealName() {
122
return $this->readLDAPRecordRealName($this->getLDAPUserData());
123
}
124
125
public function getAccountEmail() {
126
return $this->readLDAPRecordEmail($this->getLDAPUserData());
127
}
128
129
public function readLDAPRecordAccountID(array $record) {
130
$key = $this->usernameAttribute;
131
if (!strlen($key)) {
132
$key = head($this->searchAttributes);
133
}
134
return $this->readLDAPData($record, $key);
135
}
136
137
public function readLDAPRecordAccountName(array $record) {
138
return $this->readLDAPRecordAccountID($record);
139
}
140
141
public function readLDAPRecordRealName(array $record) {
142
$parts = array();
143
foreach ($this->realNameAttributes as $attribute) {
144
$parts[] = $this->readLDAPData($record, $attribute);
145
}
146
$parts = array_filter($parts);
147
148
if ($parts) {
149
return implode(' ', $parts);
150
}
151
152
return null;
153
}
154
155
public function readLDAPRecordEmail(array $record) {
156
return $this->readLDAPData($record, 'mail');
157
}
158
159
private function getLDAPUserData() {
160
if ($this->ldapUserData === null) {
161
$this->ldapUserData = $this->loadLDAPUserData();
162
}
163
164
return $this->ldapUserData;
165
}
166
167
private function readLDAPData(array $data, $key, $default = null) {
168
$list = idx($data, $key);
169
if ($list === null) {
170
// At least in some cases (and maybe in all cases) the results from
171
// ldap_search() are keyed in lowercase. If we missed on the first
172
// try, retry with a lowercase key.
173
$list = idx($data, phutil_utf8_strtolower($key));
174
}
175
176
// NOTE: In most cases, the property is an array, like:
177
//
178
// array(
179
// 'count' => 1,
180
// 0 => 'actual-value-we-want',
181
// )
182
//
183
// However, in at least the case of 'dn', the property is a bare string.
184
185
if (is_scalar($list) && strlen($list)) {
186
return $list;
187
} else if (is_array($list)) {
188
return $list[0];
189
} else {
190
return $default;
191
}
192
}
193
194
private function formatLDAPAttributeSearch($attribute, $login_user) {
195
// If the attribute contains the literal token "${login}", treat it as a
196
// query and substitute the user's login name for the token.
197
198
if (strpos($attribute, '${login}') !== false) {
199
$escaped_user = ldap_sprintf('%S', $login_user);
200
$attribute = str_replace('${login}', $escaped_user, $attribute);
201
return $attribute;
202
}
203
204
// Otherwise, treat it as a simple attribute search.
205
206
return ldap_sprintf(
207
'%Q=%S',
208
$attribute,
209
$login_user);
210
}
211
212
private function loadLDAPUserData() {
213
$conn = $this->establishConnection();
214
215
$login_user = $this->loginUsername;
216
$login_pass = $this->loginPassword;
217
218
if ($this->shouldBindWithoutIdentity()) {
219
$distinguished_name = null;
220
$search_query = null;
221
foreach ($this->searchAttributes as $attribute) {
222
$search_query = $this->formatLDAPAttributeSearch(
223
$attribute,
224
$login_user);
225
$record = $this->searchLDAPForRecord($search_query);
226
if ($record) {
227
$distinguished_name = $this->readLDAPData($record, 'dn');
228
break;
229
}
230
}
231
if ($distinguished_name === null) {
232
throw new PhutilAuthCredentialException();
233
}
234
} else {
235
$search_query = $this->formatLDAPAttributeSearch(
236
head($this->searchAttributes),
237
$login_user);
238
if ($this->activeDirectoryDomain) {
239
$distinguished_name = ldap_sprintf(
240
'%s@%Q',
241
$login_user,
242
$this->activeDirectoryDomain);
243
} else {
244
$distinguished_name = ldap_sprintf(
245
'%Q,%Q',
246
$search_query,
247
$this->baseDistinguishedName);
248
}
249
}
250
251
$this->bindLDAP($conn, $distinguished_name, $login_pass);
252
253
$result = $this->searchLDAPForRecord($search_query);
254
if (!$result) {
255
// This is unusual (since the bind succeeded) but we've seen it at least
256
// once in the wild, where the anonymous user is allowed to search but
257
// the credentialed user is not.
258
259
// If we don't have anonymous credentials, raise an explicit exception
260
// here since we'll fail a typehint if we don't return an array anyway
261
// and this is a more useful error.
262
263
// If we do have anonymous credentials, we'll rebind and try the search
264
// again below. Doing this automatically means things work correctly more
265
// often without requiring additional configuration.
266
if (!$this->shouldBindWithoutIdentity()) {
267
// No anonymous credentials, so we just fail here.
268
throw new Exception(
269
pht(
270
'LDAP: Failed to retrieve record for user "%s" when searching. '.
271
'Credentialed users may not be able to search your LDAP server. '.
272
'Try configuring anonymous credentials or fully anonymous binds.',
273
$login_user));
274
} else {
275
// Rebind as anonymous and try the search again.
276
$user = $this->anonymousUsername;
277
$pass = $this->anonymousPassword;
278
$this->bindLDAP($conn, $user, $pass);
279
280
$result = $this->searchLDAPForRecord($search_query);
281
if (!$result) {
282
throw new Exception(
283
pht(
284
'LDAP: Failed to retrieve record for user "%s" when searching '.
285
'with both user and anonymous credentials.',
286
$login_user));
287
}
288
}
289
}
290
291
return $result;
292
}
293
294
private function establishConnection() {
295
if (!$this->ldapConnection) {
296
$host = $this->hostname;
297
$port = $this->port;
298
299
$profiler = PhutilServiceProfiler::getInstance();
300
$call_id = $profiler->beginServiceCall(
301
array(
302
'type' => 'ldap',
303
'call' => 'connect',
304
'host' => $host,
305
'port' => $this->port,
306
));
307
308
$conn = @ldap_connect($host, $this->port);
309
310
$profiler->endServiceCall(
311
$call_id,
312
array(
313
'ok' => (bool)$conn,
314
));
315
316
if (!$conn) {
317
throw new Exception(
318
pht('Unable to connect to LDAP server (%s:%d).', $host, $port));
319
}
320
321
$options = array(
322
LDAP_OPT_PROTOCOL_VERSION => (int)$this->ldapVersion,
323
LDAP_OPT_REFERRALS => (int)$this->ldapReferrals,
324
);
325
326
foreach ($options as $name => $value) {
327
$ok = @ldap_set_option($conn, $name, $value);
328
if (!$ok) {
329
$this->raiseConnectionException(
330
$conn,
331
pht(
332
"Unable to set LDAP option '%s' to value '%s'!",
333
$name,
334
$value));
335
}
336
}
337
338
if ($this->ldapStartTLS) {
339
$profiler = PhutilServiceProfiler::getInstance();
340
$call_id = $profiler->beginServiceCall(
341
array(
342
'type' => 'ldap',
343
'call' => 'start-tls',
344
));
345
346
// NOTE: This boils down to a function call to ldap_start_tls_s() in
347
// C, which is a service call.
348
$ok = @ldap_start_tls($conn);
349
350
$profiler->endServiceCall(
351
$call_id,
352
array());
353
354
if (!$ok) {
355
$this->raiseConnectionException(
356
$conn,
357
pht('Unable to start TLS connection when connecting to LDAP.'));
358
}
359
}
360
361
if ($this->shouldBindWithoutIdentity()) {
362
$user = $this->anonymousUsername;
363
$pass = $this->anonymousPassword;
364
$this->bindLDAP($conn, $user, $pass);
365
}
366
367
$this->ldapConnection = $conn;
368
}
369
370
return $this->ldapConnection;
371
}
372
373
374
private function searchLDAPForRecord($dn) {
375
$conn = $this->establishConnection();
376
377
$results = $this->searchLDAP('%Q', $dn);
378
379
if (!$results) {
380
return null;
381
}
382
383
if (count($results) > 1) {
384
throw new Exception(
385
pht(
386
'LDAP record query returned more than one result. The query must '.
387
'uniquely identify a record.'));
388
}
389
390
return head($results);
391
}
392
393
public function searchLDAP($pattern /* ... */) {
394
$args = func_get_args();
395
$query = call_user_func_array('ldap_sprintf', $args);
396
397
$conn = $this->establishConnection();
398
399
$profiler = PhutilServiceProfiler::getInstance();
400
$call_id = $profiler->beginServiceCall(
401
array(
402
'type' => 'ldap',
403
'call' => 'search',
404
'dn' => $this->baseDistinguishedName,
405
'query' => $query,
406
));
407
408
$result = @ldap_search($conn, $this->baseDistinguishedName, $query);
409
410
$profiler->endServiceCall($call_id, array());
411
412
if (!$result) {
413
$this->raiseConnectionException(
414
$conn,
415
pht('LDAP search failed.'));
416
}
417
418
$entries = @ldap_get_entries($conn, $result);
419
420
if (!$entries) {
421
$this->raiseConnectionException(
422
$conn,
423
pht('Failed to get LDAP entries from search result.'));
424
}
425
426
$results = array();
427
for ($ii = 0; $ii < $entries['count']; $ii++) {
428
$results[] = $entries[$ii];
429
}
430
431
return $results;
432
}
433
434
private function raiseConnectionException($conn, $message) {
435
$errno = @ldap_errno($conn);
436
$error = @ldap_error($conn);
437
438
// This is `LDAP_INVALID_CREDENTIALS`.
439
if ($errno == 49) {
440
throw new PhutilAuthCredentialException();
441
}
442
443
if ($errno || $error) {
444
$full_message = pht(
445
"LDAP Exception: %s\nLDAP Error #%d: %s",
446
$message,
447
$errno,
448
$error);
449
} else {
450
$full_message = pht(
451
'LDAP Exception: %s',
452
$message);
453
}
454
455
throw new Exception($full_message);
456
}
457
458
private function bindLDAP($conn, $user, PhutilOpaqueEnvelope $pass) {
459
$profiler = PhutilServiceProfiler::getInstance();
460
$call_id = $profiler->beginServiceCall(
461
array(
462
'type' => 'ldap',
463
'call' => 'bind',
464
'user' => $user,
465
));
466
467
// NOTE: ldap_bind() dumps cleartext passwords into logs by default. Keep
468
// it quiet.
469
if (strlen($user)) {
470
$ok = @ldap_bind($conn, $user, $pass->openEnvelope());
471
} else {
472
$ok = @ldap_bind($conn);
473
}
474
475
$profiler->endServiceCall($call_id, array());
476
477
if (!$ok) {
478
if (strlen($user)) {
479
$this->raiseConnectionException(
480
$conn,
481
pht('Failed to bind to LDAP server (as user "%s").', $user));
482
} else {
483
$this->raiseConnectionException(
484
$conn,
485
pht('Failed to bind to LDAP server (without username).'));
486
}
487
}
488
}
489
490
491
/**
492
* Determine if this adapter should attempt to bind to the LDAP server
493
* without a user identity.
494
*
495
* Generally, we can bind directly if we have a username/password, or if the
496
* "Always Search" flag is set, indicating that the empty username and
497
* password are sufficient.
498
*
499
* @return bool True if the adapter should perform binds without identity.
500
*/
501
private function shouldBindWithoutIdentity() {
502
return $this->alwaysSearch || strlen($this->anonymousUsername);
503
}
504
505
}
506
507