Issues (70)

src/Model/LDAPGateway.php (4 issues)

1
<?php
2
3
namespace SilverStripe\LDAP\Model;
4
5
use Exception;
6
use Iterator;
7
use SilverStripe\Core\Config\Configurable;
8
use SilverStripe\Core\Extensible;
9
use SilverStripe\Core\Injector\Injectable;
10
use SilverStripe\LDAP\Iterators\LDAPIterator;
11
use Zend\Authentication\Adapter\Ldap as LdapAdapter;
12
use Zend\Authentication\AuthenticationService;
13
use Zend\Ldap\Exception\LdapException;
14
use Zend\Ldap\Filter\AbstractFilter;
15
use Zend\Ldap\Ldap;
16
use Zend\Stdlib\ErrorHandler;
17
use function ldap_control_paged_result;
18
19
/**
20
 * Class LDAPGateway
21
 *
22
 * Works within the LDAP domain model to provide basic operations.
23
 * These are exclusively used in LDAPService for constructing more complex operations.
24
 */
25
class LDAPGateway
26
{
27
    use Injectable;
28
    use Extensible;
29
    use Configurable;
30
31
    /**
32
     * @var array
33
     * @config
34
     */
35
    private static $options = [];
36
37
    /**
38
     * @var Zend\Ldap\Ldap
0 ignored issues
show
The type SilverStripe\LDAP\Model\Zend\Ldap\Ldap was not found. Did you mean Zend\Ldap\Ldap? If so, make sure to prefix the type with \.
Loading history...
39
     */
40
    private $ldap;
41
42
    public function __construct()
43
    {
44
        // due to dependency injection this class can be created without any LDAP options set
45
        // and \Zend\Ldap\Ldap will throw a warning with an empty array
46
        if (count($this->config()->options)) {
47
            $this->ldap = new Ldap($this->config()->options);
0 ignored issues
show
Documentation Bug introduced by
It seems like new Zend\Ldap\Ldap($this->config()->options) of type Zend\Ldap\Ldap is incompatible with the declared type SilverStripe\LDAP\Model\Zend\Ldap\Ldap of property $ldap.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
48
        }
49
    }
50
51
    /**
52
     * @return Ldap The underlying Zend\Ldap\Ldap class, so that methods can be called directly
53
     */
54
    public function getLdap()
55
    {
56
        return $this->ldap;
57
    }
58
59
    protected function searchWithIterator($filter, $baseDn = null, $attributes = [])
60
    {
61
        $pageSize = 500; // This must be less than the maximum size for a single page in LDAP (default 1000)
62
        $records = new LDAPIterator($this->getLdap(), $filter, $baseDn, $attributes, $pageSize);
63
        $results = $this->processSearchResults($records);
64
65
        // Reset the LDAP pagination control back to the original, otherwise all further LDAP read queries fail
66
        ldap_control_paged_result($this->getLdap()->getResource(), 1000);
67
68
        return $results;
69
    }
70
71
    /**
72
     * Query the LDAP directory with the given filter.
73
     *
74
     * @param string $filter The string to filter by, e.g. (objectClass=user)
75
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
76
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
77
     *                   Default is Zend_Ldap::SEARCH_SCOPE_SUB
78
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
79
     * @param string $sort Sort results by this attribute if given
80
     * @return array
81
     */
82
    protected function search($filter, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [], $sort = '')
83
    {
84
        $records = $this->ldap->search($filter, $baseDn, $scope, $attributes, $sort);
85
        return $this->processSearchResults($records);
86
    }
87
88
    /**
89
     * Processes results from either self::search() or self::searchAll(), expecting eitheran array of records
90
     *
91
     * @param Iterator $records
92
     * @return array
93
     */
94
    protected function processSearchResults($records)
95
    {
96
        $results = [];
97
        foreach ($records as $record) {
98
            foreach ($record as $attribute => $value) {
99
                // if the value is an array with a single value, e.g. 'samaccountname' => array(0 => 'myusername')
100
                // then make sure it's just set in the results as 'samaccountname' => 'myusername' so that it
101
                // can be used directly by ArrayData
102
                if (is_array($value) && count($value) == 1) {
103
                    $value = $value[0];
104
                }
105
106
                // ObjectGUID and ObjectSID attributes are in binary, we need to convert those to strings
107
                if ($attribute == 'objectguid') {
108
                    $value = LDAPUtil::bin_to_str_guid($value);
109
                }
110
                if ($attribute == 'objectsid') {
111
                    $value = LDAPUtil::bin_to_str_sid($value);
112
                }
113
114
                $record[$attribute] = $value;
115
            }
116
117
            $results[] = $record;
118
        }
119
120
        return $results;
121
    }
122
123
    /**
124
     * Authenticate the given username and password with LDAP.
125
     *
126
     * @param string $username
127
     * @param string $password
128
     * @return \Zend\Authentication\Result
129
     */
130
    public function authenticate($username, $password)
131
    {
132
        $auth = new AuthenticationService();
133
        $adapter = new LdapAdapter([$this->config()->options], $username, $password);
134
        return $auth->authenticate($adapter);
135
    }
136
137
    /**
138
     * Query for LDAP nodes (organizational units, containers, and domains).
139
     *
140
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
141
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
142
     *          Default is Zend_Ldap::SEARCH_SCOPE_SUB
143
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
144
     * @param string $sort Sort results by this attribute if given
145
     * @return array
146
     */
147
    public function getNodes($baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [], $sort = '')
148
    {
149
        $filter = '(|(objectClass=organizationalUnit)(objectClass=container)(objectClass=domain))';
150
151
        $this->extend('updateNodesFilter', $filter);
152
153
        return $this->search(
154
            $filter,
155
            $baseDn,
156
            $scope,
157
            $attributes,
158
            $sort
159
        );
160
    }
161
162
    /**
163
     * Query for LDAP groups.
164
     *
165
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
166
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
167
     *          Default is Zend_Ldap::SEARCH_SCOPE_SUB
168
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
169
     * @param string $sort Sort results by this attribute if given
170
     * @return array
171
     */
172
    public function getGroups($baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [], $sort = '')
173
    {
174
        $filter = '(objectClass=group)';
175
176
        $this->extend('updateGroupsFilter', $filter);
177
178
        return $this->search($filter, $baseDn, $scope, $attributes, $sort);
179
    }
180
181
    /**
182
     * Return all nested AD groups underneath a specific DN
183
     *
184
     * @param string $dn
185
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
186
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
187
     *                  Default is Zend_Ldap::SEARCH_SCOPE_SUB
188
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
189
     * @return array
190
     */
191
    public function getNestedGroups($dn, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
192
    {
193
        $filter = sprintf('(&(objectClass=group)(memberOf:1.2.840.113556.1.4.1941:=%s))', $dn);
194
195
        $this->extend('updateNestedGroupsFilter', $filter);
196
197
        return $this->search(
198
            $filter,
199
            $baseDn,
200
            $scope,
201
            $attributes
202
        );
203
    }
204
205
    /**
206
     * Return a particular LDAP group by objectGUID value.
207
     *
208
     * @param string $guid
209
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
210
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
211
     *                  Default is Zend_Ldap::SEARCH_SCOPE_SUB
212
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
213
     * @return array
214
     */
215
    public function getGroupByGUID($guid, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
216
    {
217
        return $this->search(
218
            sprintf('(&(objectClass=group)(objectGUID=%s))', LDAPUtil::str_to_hex_guid($guid, true)),
219
            $baseDn,
220
            $scope,
221
            $attributes
222
        );
223
    }
224
225
    /**
226
     * Return a particular LDAP group by DN value.
227
     *
228
     * @param string $dn
229
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
230
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
231
     *              Default is Zend_Ldap::SEARCH_SCOPE_SUB
232
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
233
     * @return array
234
     */
235
    public function getGroupByDN($dn, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
236
    {
237
        return $this->search(
238
            sprintf('(&(objectClass=group)(distinguishedname=%s))', $dn),
239
            $baseDn,
240
            $scope,
241
            $attributes
242
        );
243
    }
244
245
    /**
246
     * Query for LDAP users, but don't include built-in user accounts.
247
     *
248
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
249
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
250
     *                  Default is Zend_Ldap::SEARCH_SCOPE_SUB
251
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
252
     * @param string $sort Sort results by this attribute if given
253
     * @return array
254
     */
255
    public function getUsers($baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [], $sort = '')
256
    {
257
        $filter = '(&(objectClass=user)(!(objectClass=computer))(!(samaccountname=Guest))(!(samaccountname=Administrator))(!(samaccountname=krbtgt)))';
258
259
        $this->extend('updateUsersFilter', $filter);
260
261
        return $this->search(
262
            $filter,
263
            $baseDn,
264
            $scope,
265
            $attributes,
266
            $sort
267
        );
268
    }
269
270
    /**
271
     * Query for LDAP users, but don't include built-in user accounts. Iterate over all users, regardless of the paging
272
     * size control built into the LDAP server.
273
     *
274
     * @param string|null $baseDn The DN to search within. Defaults to the base DN applied to the connection.
275
     * @param array $attributes Specify user attributes to be returned. Defaults to returning all attributes.
276
     * @return array
277
     */
278
    public function getUsersWithIterator($baseDn = null, $attributes = [])
279
    {
280
        $filter = '(&(objectClass=user)(!(objectClass=computer))(!(samaccountname=Guest))(!(samaccountname=Administrator))(!(samaccountname=krbtgt)))';
281
282
        $this->extend('updateUsersWithIteratorFilter', $filter);
283
284
        return $this->searchWithIterator(
285
            $filter,
286
            $baseDn,
287
            $attributes
288
        );
289
    }
290
291
    /**
292
     * Return a particular LDAP user by objectGUID value.
293
     *
294
     * @param string $guid
295
     * @return array
296
     */
297
    public function getUserByGUID($guid, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
298
    {
299
        return $this->search(
300
            sprintf('(&(objectClass=user)(objectGUID=%s))', LDAPUtil::str_to_hex_guid($guid, true)),
301
            $baseDn,
302
            $scope,
303
            $attributes
304
        );
305
    }
306
307
    /**
308
     * Return a particular LDAP user by DN value.
309
     *
310
     * @param string $dn
311
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
312
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
313
     *                  Default is Zend_Ldap::SEARCH_SCOPE_SUB
314
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
315
     * @return array
316
     */
317
    public function getUserByDN($dn, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
318
    {
319
        return $this->search(
320
            sprintf('(&(objectClass=user)(distinguishedname=%s))', $dn),
321
            $baseDn,
322
            $scope,
323
            $attributes
324
        );
325
    }
326
327
    /**
328
     * Return a particular LDAP user by mail value.
329
     *
330
     * @param string $email
331
     * @return array
332
     */
333
    public function getUserByEmail($email, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
334
    {
335
        return $this->search(
336
            sprintf('(&(objectClass=user)(mail=%s))', AbstractFilter::escapeValue($email)),
0 ignored issues
show
Zend\Ldap\Filter\Abstrac...er::escapeValue($email) of type array<integer,string> is incompatible with the type string expected by parameter $args of sprintf(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

336
            sprintf('(&(objectClass=user)(mail=%s))', /** @scrutinizer ignore-type */ AbstractFilter::escapeValue($email)),
Loading history...
337
            $baseDn,
338
            $scope,
339
            $attributes
340
        );
341
    }
342
343
    /**
344
     * Get a specific user's data from LDAP
345
     *
346
     * @param string $username
347
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
348
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
349
     *                      Default is Zend_Ldap::SEARCH_SCOPE_SUB
350
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
351
     * @return array
352
     * @throws Exception
353
     */
354
    public function getUserByUsername($username, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
355
    {
356
        $options = $this->config()->options;
357
        $option = isset($options['accountCanonicalForm']) ? $options['accountCanonicalForm'] : null;
358
359
        // will translate the username to [email protected], username or foo\user depending on the
360
        // $options['accountCanonicalForm']
361
        $username = $this->ldap->getCanonicalAccountName($username, $option);
362
        switch ($option) {
363
            case Ldap::ACCTNAME_FORM_USERNAME: // traditional style usernames, e.g. alice
364
                $filter = sprintf('(&(objectClass=user)(samaccountname=%s))', AbstractFilter::escapeValue($username));
0 ignored issues
show
Zend\Ldap\Filter\Abstrac...:escapeValue($username) of type array is incompatible with the type string expected by parameter $args of sprintf(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

364
                $filter = sprintf('(&(objectClass=user)(samaccountname=%s))', /** @scrutinizer ignore-type */ AbstractFilter::escapeValue($username));
Loading history...
365
                break;
366
            case Ldap::ACCTNAME_FORM_BACKSLASH: // backslash style usernames, e.g. FOO\alice
367
                // @todo Not supported yet!
368
                throw new Exception('Backslash style not supported in LDAPGateway::getUserByUsername()!');
369
                break;
370
            case Ldap::ACCTNAME_FORM_PRINCIPAL: // principal style usernames, e.g. [email protected]
371
                $filter = sprintf(
372
                    '(&(objectClass=user)(userprincipalname=%s))',
373
                    AbstractFilter::escapeValue($username)
374
                );
375
                break;
376
            case Ldap::ACCTNAME_FORM_DN: // distinguished name, e.g. CN=someone,DC=example,DC=co,DC=nz
377
                // @todo Not supported yet!
378
                throw new Exception('DN style not supported in LDAPGateway::getUserByUsername()!');
379
                break;
380
            default: // default to principal style
381
                $filter = sprintf(
382
                    '(&(objectClass=user)(userprincipalname=%s))',
383
                    AbstractFilter::escapeValue($username)
384
                );
385
                break;
386
        }
387
388
        return $this->search($filter, $baseDn, $scope, $attributes);
389
    }
390
391
    /**
392
     * Get a canonical username from the record based on the connection settings.
393
     *
394
     * @param  array $data
395
     * @return string
396
     * @throws Exception
397
     */
398
    public function getCanonicalUsername($data)
399
    {
400
        $options = $this->config()->options;
401
        $option = isset($options['accountCanonicalForm']) ? $options['accountCanonicalForm'] : null;
402
        switch ($option) {
403
            case Ldap::ACCTNAME_FORM_USERNAME: // traditional style usernames, e.g. alice
404
                if (empty($data['samaccountname'])) {
405
                    throw new \Exception('Could not extract canonical username: samaccountname field missing');
406
                }
407
                return $data['samaccountname'];
408
            case Ldap::ACCTNAME_FORM_BACKSLASH: // backslash style usernames, e.g. FOO\alice
409
                // @todo Not supported yet!
410
                throw new Exception('Backslash style not supported in LDAPGateway::getUsernameByEmail()!');
411
            case Ldap::ACCTNAME_FORM_PRINCIPAL: // principal style usernames, e.g. [email protected]
412
                if (empty($data['userprincipalname'])) {
413
                    throw new Exception('Could not extract canonical username: userprincipalname field missing');
414
                }
415
                return $data['userprincipalname'];
416
            default: // default to principal style
417
                if (empty($data['userprincipalname'])) {
418
                    throw new Exception('Could not extract canonical username: userprincipalname field missing');
419
                }
420
                return $data['userprincipalname'];
421
        }
422
    }
423
424
    /**
425
     * Changes user password via LDAP.
426
     *
427
     * Change password is different to administrative password reset in that it will respect the password
428
     * history policy. This is achieved by sending a remove followed by an add in one batch (which is different
429
     * to an ordinary attribute modification operation).
430
     *
431
     * @param string $dn Location to update the entry at.
432
     * @param string $password New password to set.
433
     * @param string $oldPassword Old password is needed to trigger a password change.
434
     * @throws \Zend\Ldap\Exception\LdapException
435
     * @throws Exception
436
     */
437
    public function changePassword($dn, $password, $oldPassword)
438
    {
439
        if (!function_exists('ldap_modify_batch')) {
440
            // Password change is unsupported - missing PHP API method.
441
            $this->resetPassword($dn, $password);
442
            return;
443
        }
444
445
        $modifications = [
446
            [
447
                'attrib'  => 'unicodePwd',
448
                'modtype' => LDAP_MODIFY_BATCH_REMOVE,
449
                'values'  => [iconv('UTF-8', 'UTF-16LE', sprintf('"%s"', $oldPassword))],
450
            ],
451
            [
452
                'attrib'  => 'unicodePwd',
453
                'modtype' => LDAP_MODIFY_BATCH_ADD,
454
                'values'  => [iconv('UTF-8', 'UTF-16LE', sprintf('"%s"', $password))],
455
            ],
456
        ];
457
        // Batch attribute operations are not supported by Zend_Ldap, use raw resource.
458
        $ldapConn = $this->ldap->getResource();
459
        ErrorHandler::start(E_WARNING);
460
        $succeeded = ldap_modify_batch($ldapConn, $dn, $modifications);
461
        ErrorHandler::stop();
462
        if (!$succeeded) {
463
            throw new Exception($this->getLastPasswordError());
464
        }
465
    }
466
467
    /**
468
     * Administrative password reset.
469
     *
470
     * This is different to password change - it does not require old password, but also
471
     * it does not respect password history policy setting.
472
     *
473
     * @param string $dn Location to update the entry at.
474
     * @param string $password New password to set.
475
     * @throws \Zend\Ldap\Exception\LdapException
476
     * @throws Exception
477
     */
478
    public function resetPassword($dn, $password)
479
    {
480
        try {
481
            $this->update(
482
                $dn,
483
                ['unicodePwd' => iconv('UTF-8', 'UTF-16LE', sprintf('"%s"', $password))]
484
            );
485
        } catch (LdapException $e) {
486
            throw new Exception($this->getLastPasswordError());
487
        }
488
    }
489
490
    /**
491
     * Updates an LDAP object.
492
     *
493
     * For this work you might need that LDAP connection is bind:ed with a user with
494
     * enough permissions to change attributes and that the LDAP connection is using
495
     * SSL/TLS. It depends on the server setup.
496
     *
497
     * If there are some errors, the underlying LDAP library should throw an Exception
498
     *
499
     * @param string $dn Location to update the entry at.
500
     * @param array $attributes An associative array of attributes.
501
     * @throws \Zend\Ldap\Exception\LdapException
502
     */
503
    public function update($dn, array $attributes)
504
    {
505
        $this->ldap->update($dn, $attributes);
506
    }
507
508
    /**
509
     * Deletes an LDAP object.
510
     *
511
     * @param string $dn Location of object to delete
512
     * @param bool $recursively Recursively delete nested objects?
513
     * @throws \Zend\Ldap\Exception\LdapException
514
     */
515
    public function delete($dn, $recursively = false)
516
    {
517
        $this->ldap->delete($dn, $recursively);
518
    }
519
520
    /**
521
     * Move an LDAP object from it's original DN location to another.
522
     * This effectively copies the object then deletes the original one.
523
     *
524
     * @param string $fromDn
525
     * @param string $toDn
526
     * @param bool $recursively Recursively move nested objects?
527
     */
528
    public function move($fromDn, $toDn, $recursively = false)
529
    {
530
        $this->ldap->move($fromDn, $toDn, $recursively);
531
    }
532
533
    /**
534
     * Add an LDAP object.
535
     *
536
     * For this work you might need that LDAP connection is bind:ed with a user with
537
     * enough permissions to change attributes and that the LDAP connection is using
538
     * SSL/TLS. It depends on the server setup.
539
     *
540
     * @param string $dn Location to add the entry at.
541
     * @param array $attributes An associative array of attributes.
542
     * @throws \Zend\Ldap\Exception\LdapException
543
     */
544
    public function add($dn, array $attributes)
545
    {
546
        $this->ldap->add($dn, $attributes);
547
    }
548
549
    /**
550
     * @return string
551
     */
552
    private function getLastPasswordError()
553
    {
554
        $defaultError = _t(
555
            'SilverStripe\\LDAP\\Authenticators\\LDAPAuthenticator.CANTCHANGEPASSWORD',
556
            'We couldn\'t change your password, please contact an administrator.'
557
        );
558
        $error = '';
559
        ldap_get_option($this->ldap->getResource(), LDAP_OPT_ERROR_STRING, $error);
560
561
        if (!$error) {
562
            return $defaultError;
563
        }
564
565
        // Try to parse the exception to get the error message to display to user, eg:
566
        // 0000052D: Constraint violation - check_password_restrictions: the password does not meet the complexity criteria!)
567
        // 0000052D: Constraint violation - check_password_restrictions: the password was already used (in history)!)
568
569
        // We are only interested in the explanatory message after the last colon.
570
        $message = preg_replace('/.*:/', '', $error);
571
        if ($error) {
572
            return ucfirst(trim($message));
573
        }
574
575
        return $defaultError;
576
    }
577
}
578