Passed
Pull Request — master (#18)
by Matt
02:29
created

LDAPGateway::searchWithIterator()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 5
nc 1
nop 3
dl 0
loc 10
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\LDAP\Model;
4
5
use Exception;
6
use Iterator;
7
use function ldap_control_paged_result;
8
use SilverStripe\Core\Injector\Injectable;
9
use SilverStripe\Core\Config\Configurable;
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
18
/**
19
 * Class LDAPGateway
20
 *
21
 * Works within the LDAP domain model to provide basic operations.
22
 * These are exclusively used in LDAPService for constructing more complex operations.
23
 */
24
class LDAPGateway
25
{
26
    use Injectable;
27
    use Configurable;
28
29
    /**
30
     * @var array
31
     * @config
32
     */
33
    private static $options = [];
34
35
    /**
36
     * @var Zend\Ldap\Ldap
0 ignored issues
show
Bug introduced by
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...
37
     */
38
    private $ldap;
39
40
    public function __construct()
41
    {
42
        // due to dependency injection this class can be created without any LDAP options set
43
        // and \Zend\Ldap\Ldap will throw a warning with an empty array
44
        if (count($this->config()->options)) {
45
            $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...
46
        }
47
    }
48
49
    /**
50
     * @return Ldap The underlying Zend\Ldap\Ldap class, so that methods can be called directly
51
     */
52
    public function getLdap()
53
    {
54
        return $this->ldap;
55
    }
56
57
    protected function searchWithIterator($filter, $baseDn = null, $attributes = [])
58
    {
59
        $pageSize = 500; // This must be less than the maximum size for a single page in LDAP (default 1000)
60
        $records = new LDAPIterator($this->getLdap(), $filter, $baseDn, $attributes, $pageSize);
61
        $results = $this->processSearchResults($records);
62
63
        // Reset the LDAP pagination control back to the original, otherwise all further LDAP read queries fail
64
        ldap_control_paged_result($this->getLdap()->getResource(), 1000);
65
66
        return $results;
67
    }
68
69
    /**
70
     * Query the LDAP directory with the given filter.
71
     *
72
     * @param string $filter The string to filter by, e.g. (objectClass=user)
73
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
74
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
75
     *                   Default is Zend_Ldap::SEARCH_SCOPE_SUB
76
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
77
     * @param string $sort Sort results by this attribute if given
78
     * @return array
79
     */
80
    protected function search($filter, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [], $sort = '')
81
    {
82
        $records = $this->ldap->search($filter, $baseDn, $scope, $attributes, $sort);
83
        return $this->processSearchResults($records);
84
    }
85
86
    /**
87
     * Processes results from either self::search() or self::searchAll(), expecting eitheran array of records
88
     *
89
     * @param Iterator $records
90
     * @return array
91
     */
92
    private function processSearchResults($records)
93
    {
94
        $results = [];
95
        foreach ($records as $record) {
96
            foreach ($record as $attribute => $value) {
97
                // if the value is an array with a single value, e.g. 'samaccountname' => array(0 => 'myusername')
98
                // then make sure it's just set in the results as 'samaccountname' => 'myusername' so that it
99
                // can be used directly by ArrayData
100
                if (is_array($value) && count($value) == 1) {
101
                    $value = $value[0];
102
                }
103
104
                // ObjectGUID and ObjectSID attributes are in binary, we need to convert those to strings
105
                if ($attribute == 'objectguid') {
106
                    $value = LDAPUtil::bin_to_str_guid($value);
107
                }
108
                if ($attribute == 'objectsid') {
109
                    $value = LDAPUtil::bin_to_str_sid($value);
110
                }
111
112
                $record[$attribute] = $value;
113
            }
114
115
            $results[] = $record;
116
        }
117
118
        return $results;
119
    }
120
121
    /**
122
     * Authenticate the given username and password with LDAP.
123
     *
124
     * @param string $username
125
     * @param string $password
126
     * @return \Zend\Authentication\Result
127
     */
128
    public function authenticate($username, $password)
129
    {
130
        $auth = new AuthenticationService();
131
        $adapter = new LdapAdapter([$this->config()->options], $username, $password);
132
        return $auth->authenticate($adapter);
133
    }
134
135
    /**
136
     * Query for LDAP nodes (organizational units, containers, and domains).
137
     *
138
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
139
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
140
     *          Default is Zend_Ldap::SEARCH_SCOPE_SUB
141
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
142
     * @param string $sort Sort results by this attribute if given
143
     * @return array
144
     */
145
    public function getNodes($baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [], $sort = '')
146
    {
147
        return $this->search(
148
            '(|(objectClass=organizationalUnit)(objectClass=container)(objectClass=domain))',
149
            $baseDn,
150
            $scope,
151
            $attributes,
152
            $sort
153
        );
154
    }
155
156
    /**
157
     * Query for LDAP groups.
158
     *
159
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
160
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
161
     *          Default is Zend_Ldap::SEARCH_SCOPE_SUB
162
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
163
     * @param string $sort Sort results by this attribute if given
164
     * @return array
165
     */
166
    public function getGroups($baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [], $sort = '')
167
    {
168
        return $this->search('(objectClass=group)', $baseDn, $scope, $attributes, $sort);
169
    }
170
171
    /**
172
     * Return all nested AD groups underneath a specific DN
173
     *
174
     * @param string $dn
175
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
176
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
177
     *                  Default is Zend_Ldap::SEARCH_SCOPE_SUB
178
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
179
     * @return array
180
     */
181
    public function getNestedGroups($dn, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
182
    {
183
        return $this->search(
184
            sprintf('(&(objectClass=group)(memberOf:1.2.840.113556.1.4.1941:=%s))', $dn),
185
            $baseDn,
186
            $scope,
187
            $attributes
188
        );
189
    }
190
191
    /**
192
     * Return a particular LDAP group by objectGUID value.
193
     *
194
     * @param string $guid
195
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
196
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
197
     *                  Default is Zend_Ldap::SEARCH_SCOPE_SUB
198
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
199
     * @return array
200
     */
201
    public function getGroupByGUID($guid, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
202
    {
203
        return $this->search(
204
            sprintf('(&(objectClass=group)(objectGUID=%s))', LDAPUtil::str_to_hex_guid($guid, true)),
205
            $baseDn,
206
            $scope,
207
            $attributes
208
        );
209
    }
210
211
    /**
212
     * Return a particular LDAP group by DN value.
213
     *
214
     * @param string $dn
215
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
216
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
217
     *              Default is Zend_Ldap::SEARCH_SCOPE_SUB
218
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
219
     * @return array
220
     */
221
    public function getGroupByDN($dn, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
222
    {
223
        return $this->search(
224
            sprintf('(&(objectClass=group)(distinguishedname=%s))', $dn),
225
            $baseDn,
226
            $scope,
227
            $attributes
228
        );
229
    }
230
231
    /**
232
     * Query for LDAP users, but don't include built-in user accounts.
233
     *
234
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
235
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
236
     *                  Default is Zend_Ldap::SEARCH_SCOPE_SUB
237
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
238
     * @param string $sort Sort results by this attribute if given
239
     * @return array
240
     */
241
    public function getUsers($baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [], $sort = '')
242
    {
243
        return $this->search(
244
            '(&(objectClass=user)(!(objectClass=computer))(!(samaccountname=Guest))(!(samaccountname=Administrator))(!(samaccountname=krbtgt)))',
245
            $baseDn,
246
            $scope,
247
            $attributes,
248
            $sort
249
        );
250
    }
251
252
    /**
253
     * Query for LDAP users, but don't include built-in user accounts. Iterate over all users, regardless of the paging
254
     * size control built into the LDAP server.
255
     *
256
     * @param string|null $baseDn The DN to search within. Defaults to the base DN applied to the connection.
257
     * @param array $attributes Specify user attributes to be returned. Defaults to returning all attributes.
258
     * @return array
259
     */
260
    public function getUsersWithIterator($baseDn = null, $attributes = [])
261
    {
262
        return $this->searchWithIterator(
263
            '(&(objectClass=user)(!(objectClass=computer))(!(samaccountname=Guest))(!(samaccountname=Administrator))(!(samaccountname=krbtgt)))',
264
            $baseDn,
265
            $attributes
266
        );
267
    }
268
269
    /**
270
     * Return a particular LDAP user by objectGUID value.
271
     *
272
     * @param string $guid
273
     * @return array
274
     */
275
    public function getUserByGUID($guid, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
276
    {
277
        return $this->search(
278
            sprintf('(&(objectClass=user)(objectGUID=%s))', LDAPUtil::str_to_hex_guid($guid, true)),
279
            $baseDn,
280
            $scope,
281
            $attributes
282
        );
283
    }
284
285
    /**
286
     * Return a particular LDAP user by DN value.
287
     *
288
     * @param string $dn
289
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
290
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
291
     *                  Default is Zend_Ldap::SEARCH_SCOPE_SUB
292
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
293
     * @return array
294
     */
295
    public function getUserByDN($dn, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
296
    {
297
        return $this->search(
298
            sprintf('(&(objectClass=user)(distinguishedname=%s))', $dn),
299
            $baseDn,
300
            $scope,
301
            $attributes
302
        );
303
    }
304
305
    /**
306
     * Return a particular LDAP user by mail value.
307
     *
308
     * @param string $email
309
     * @return array
310
     */
311
    public function getUserByEmail($email, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
312
    {
313
        return $this->search(
314
            sprintf('(&(objectClass=user)(mail=%s))', AbstractFilter::escapeValue($email)),
0 ignored issues
show
Bug introduced by
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

314
            sprintf('(&(objectClass=user)(mail=%s))', /** @scrutinizer ignore-type */ AbstractFilter::escapeValue($email)),
Loading history...
315
            $baseDn,
316
            $scope,
317
            $attributes
318
        );
319
    }
320
321
    /**
322
     * Get a specific user's data from LDAP
323
     *
324
     * @param string $username
325
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
326
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
327
     *                      Default is Zend_Ldap::SEARCH_SCOPE_SUB
328
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
329
     * @return array
330
     * @throws Exception
331
     */
332
    public function getUserByUsername($username, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
333
    {
334
        $options = $this->config()->options;
335
        $option = isset($options['accountCanonicalForm']) ? $options['accountCanonicalForm'] : null;
336
337
        // will translate the username to [email protected], username or foo\user depending on the
338
        // $options['accountCanonicalForm']
339
        $username = $this->ldap->getCanonicalAccountName($username, $option);
340
        switch ($option) {
341
            case Ldap::ACCTNAME_FORM_USERNAME: // traditional style usernames, e.g. alice
342
                $filter = sprintf('(&(objectClass=user)(samaccountname=%s))', AbstractFilter::escapeValue($username));
0 ignored issues
show
Bug introduced by
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

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