Passed
Push — master ( 3c190c...2857c2 )
by Robbie
03:26
created

LDAPGateway::getCanonicalUsername()   C

Complexity

Conditions 8
Paths 6

Size

Total Lines 23
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 17
nc 6
nop 1
dl 0
loc 23
rs 6.1403
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\LDAP\Model;
4
5
use Exception;
6
use SilverStripe\Core\Injector\Injectable;
7
use SilverStripe\Core\Config\Configurable;
8
use Zend\Authentication\Adapter\Ldap as LdapAdapter;
9
use Zend\Authentication\AuthenticationService;
10
use Zend\Ldap\Exception\LdapException;
11
use Zend\Ldap\Filter\AbstractFilter;
12
use Zend\Ldap\Ldap;
13
use Zend\Stdlib\ErrorHandler;
14
15
/**
16
 * Class LDAPGateway
17
 *
18
 * Works within the LDAP domain model to provide basic operations.
19
 * These are exclusively used in LDAPService for constructing more complex operations.
20
 */
21
class LDAPGateway
22
{
23
    use Injectable;
24
    use Configurable;
25
26
    /**
27
     * @var array
28
     * @config
29
     */
30
    private static $options = [];
0 ignored issues
show
Unused Code introduced by
The property $options is not used and could be removed.

This check marks private properties in classes that are never used. Those properties can be removed.

Loading history...
31
32
    /**
33
     * @var Ldap
34
     */
35
    private $ldap;
36
37
    public function __construct()
38
    {
39
        // due to dependency injection this class can be created without any LDAP options set
40
        // and \Zend\Ldap\Ldap will throw a warning with an empty array
41
        if (count($this->config()->options)) {
42
            $this->ldap = new Ldap($this->config()->options);
43
        }
44
    }
45
46
    /**
47
     * Query the LDAP directory with the given filter.
48
     *
49
     * @param string $filter The string to filter by, e.g. (objectClass=user)
50
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
51
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
52
     *                   Default is Zend_Ldap::SEARCH_SCOPE_SUB
53
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
54
     * @param string $sort Sort results by this attribute if given
55
     * @return array
56
     */
57
    protected function search($filter, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [], $sort = '')
58
    {
59
        $records = $this->ldap->search($filter, $baseDn, $scope, $attributes, $sort);
60
61
        $results = [];
62 View Code Duplication
        foreach ($records as $record) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
63
            foreach ($record as $attribute => $value) {
64
                // if the value is an array with a single value, e.g. 'samaccountname' => array(0 => 'myusername')
65
                // then make sure it's just set in the results as 'samaccountname' => 'myusername' so that it
66
                // can be used directly by ArrayData
67
                if (is_array($value) && count($value) == 1) {
68
                    $value = $value[0];
69
                }
70
71
                // ObjectGUID and ObjectSID attributes are in binary, we need to convert those to strings
72
                if ($attribute == 'objectguid') {
73
                    $value = LDAPUtil::bin_to_str_guid($value);
74
                }
75
                if ($attribute == 'objectsid') {
76
                    $value = LDAPUtil::bin_to_str_sid($value);
77
                }
78
79
                $record[$attribute] = $value;
80
            }
81
82
            $results[] = $record;
83
        }
84
85
        return $results;
86
    }
87
88
    /**
89
     * Authenticate the given username and password with LDAP.
90
     *
91
     * @param string $username
92
     * @param string $password
93
     * @return \Zend\Authentication\Result
94
     */
95
    public function authenticate($username, $password)
96
    {
97
        $auth = new AuthenticationService();
98
        $adapter = new LdapAdapter([$this->config()->options], $username, $password);
99
        return $auth->authenticate($adapter);
100
    }
101
102
    /**
103
     * Query for LDAP nodes (organizational units, containers, and domains).
104
     *
105
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
106
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
107
     *          Default is Zend_Ldap::SEARCH_SCOPE_SUB
108
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
109
     * @param string $sort Sort results by this attribute if given
110
     * @return array
111
     */
112
    public function getNodes($baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [], $sort = '')
113
    {
114
        return $this->search(
115
            '(|(objectClass=organizationalUnit)(objectClass=container)(objectClass=domain))',
116
            $baseDn,
117
            $scope,
118
            $attributes,
119
            $sort
120
        );
121
    }
122
123
    /**
124
     * Query for LDAP groups.
125
     *
126
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
127
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
128
     *          Default is Zend_Ldap::SEARCH_SCOPE_SUB
129
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
130
     * @param string $sort Sort results by this attribute if given
131
     * @return array
132
     */
133
    public function getGroups($baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [], $sort = '')
134
    {
135
        return $this->search('(objectClass=group)', $baseDn, $scope, $attributes, $sort);
136
    }
137
138
    /**
139
     * Return all nested AD groups underneath a specific DN
140
     *
141
     * @param string $dn
142
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
143
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
144
     *                  Default is Zend_Ldap::SEARCH_SCOPE_SUB
145
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
146
     * @return array
147
     */
148
    public function getNestedGroups($dn, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
149
    {
150
        return $this->search(
151
            sprintf('(&(objectClass=group)(memberOf:1.2.840.113556.1.4.1941:=%s))', $dn),
152
            $baseDn,
153
            $scope,
154
            $attributes
155
        );
156
    }
157
158
    /**
159
     * Return a particular LDAP group by objectGUID value.
160
     *
161
     * @param string $guid
162
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
163
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
164
     *                  Default is Zend_Ldap::SEARCH_SCOPE_SUB
165
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
166
     * @return array
167
     */
168 View Code Duplication
    public function getGroupByGUID($guid, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
169
    {
170
        return $this->search(
171
            sprintf('(&(objectClass=group)(objectGUID=%s))', LDAPUtil::str_to_hex_guid($guid, true)),
172
            $baseDn,
173
            $scope,
174
            $attributes
175
        );
176
    }
177
178
    /**
179
     * Return a particular LDAP group by DN value.
180
     *
181
     * @param string $dn
182
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
183
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
184
     *              Default is Zend_Ldap::SEARCH_SCOPE_SUB
185
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
186
     * @return array
187
     */
188
    public function getGroupByDN($dn, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
189
    {
190
        return $this->search(
191
            sprintf('(&(objectClass=group)(distinguishedname=%s))', $dn),
192
            $baseDn,
193
            $scope,
194
            $attributes
195
        );
196
    }
197
198
    /**
199
     * Query for LDAP users, but don't include built-in user accounts.
200
     *
201
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
202
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
203
     *                  Default is Zend_Ldap::SEARCH_SCOPE_SUB
204
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
205
     * @param string $sort Sort results by this attribute if given
206
     * @return array
207
     */
208
    public function getUsers($baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [], $sort = '')
209
    {
210
        return $this->search(
211
            '(&(objectClass=user)(!(objectClass=computer))(!(samaccountname=Guest))(!(samaccountname=Administrator))(!(samaccountname=krbtgt)))',
212
            $baseDn,
213
            $scope,
214
            $attributes,
215
            $sort
216
        );
217
    }
218
219
    /**
220
     * Return a particular LDAP user by objectGUID value.
221
     *
222
     * @param string $guid
223
     * @return array
224
     */
225 View Code Duplication
    public function getUserByGUID($guid, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
226
    {
227
        return $this->search(
228
            sprintf('(&(objectClass=user)(objectGUID=%s))', LDAPUtil::str_to_hex_guid($guid, true)),
229
            $baseDn,
230
            $scope,
231
            $attributes
232
        );
233
    }
234
235
    /**
236
     * Return a particular LDAP user by DN value.
237
     *
238
     * @param string $dn
239
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
240
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
241
     *                  Default is Zend_Ldap::SEARCH_SCOPE_SUB
242
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
243
     * @return array
244
     */
245
    public function getUserByDN($dn, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
246
    {
247
        return $this->search(
248
            sprintf('(&(objectClass=user)(distinguishedname=%s))', $dn),
249
            $baseDn,
250
            $scope,
251
            $attributes
252
        );
253
    }
254
255
    /**
256
     * Return a particular LDAP user by mail value.
257
     *
258
     * @param string $email
259
     * @return array
260
     */
261
    public function getUserByEmail($email, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
262
    {
263
        return $this->search(
264
            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 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

264
            sprintf('(&(objectClass=user)(mail=%s))', /** @scrutinizer ignore-type */ AbstractFilter::escapeValue($email)),
Loading history...
265
            $baseDn,
266
            $scope,
267
            $attributes
268
        );
269
    }
270
271
    /**
272
     * Get a specific user's data from LDAP
273
     *
274
     * @param string $username
275
     * @param null|string $baseDn The DN to search from. Default is the baseDn option in the connection if not given
276
     * @param int $scope The scope to perform the search. Zend_Ldap::SEARCH_SCOPE_ONE, Zend_LDAP::SEARCH_SCOPE_BASE.
277
     *                      Default is Zend_Ldap::SEARCH_SCOPE_SUB
278
     * @param array $attributes Restrict to specific AD attributes. An empty array will return all attributes
279
     * @return array
280
     * @throws Exception
281
     */
282
    public function getUserByUsername($username, $baseDn = null, $scope = Ldap::SEARCH_SCOPE_SUB, $attributes = [])
283
    {
284
        $options = $this->config()->options;
285
        $option = isset($options['accountCanonicalForm']) ? $options['accountCanonicalForm'] : null;
286
287
        // will translate the username to [email protected], username or foo\user depending on the
288
        // $options['accountCanonicalForm']
289
        $username = $this->ldap->getCanonicalAccountName($username, $option);
290
        switch ($option) {
291
            case Ldap::ACCTNAME_FORM_USERNAME: // traditional style usernames, e.g. alice
292
                $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

292
                $filter = sprintf('(&(objectClass=user)(samaccountname=%s))', /** @scrutinizer ignore-type */ AbstractFilter::escapeValue($username));
Loading history...
293
                break;
294
            case Ldap::ACCTNAME_FORM_BACKSLASH: // backslash style usernames, e.g. FOO\alice
295
                // @todo Not supported yet!
296
                throw new Exception('Backslash style not supported in LDAPGateway::getUserByUsername()!');
297
                break;
298
            case Ldap::ACCTNAME_FORM_PRINCIPAL: // principal style usernames, e.g. [email protected]
299
                $filter = sprintf(
300
                    '(&(objectClass=user)(userprincipalname=%s))',
301
                    AbstractFilter::escapeValue($username)
302
                );
303
                break;
304
            case Ldap::ACCTNAME_FORM_DN: // distinguished name, e.g. CN=someone,DC=example,DC=co,DC=nz
305
                // @todo Not supported yet!
306
                throw new Exception('DN style not supported in LDAPGateway::getUserByUsername()!');
307
                break;
308
            default: // default to principal style
309
                $filter = sprintf(
310
                    '(&(objectClass=user)(userprincipalname=%s))',
311
                    AbstractFilter::escapeValue($username)
312
                );
313
                break;
314
        }
315
316
        return $this->search($filter, $baseDn, $scope, $attributes);
317
    }
318
319
    /**
320
     * Get a canonical username from the record based on the connection settings.
321
     *
322
     * @param  array $data
323
     * @return string
324
     * @throws Exception
325
     */
326
    public function getCanonicalUsername($data)
327
    {
328
        $options = $this->config()->options;
329
        $option = isset($options['accountCanonicalForm']) ? $options['accountCanonicalForm'] : null;
330
        switch ($option) {
331
            case Ldap::ACCTNAME_FORM_USERNAME: // traditional style usernames, e.g. alice
332
                if (empty($data['samaccountname'])) {
333
                    throw new \Exception('Could not extract canonical username: samaccountname field missing');
334
                }
335
                return $data['samaccountname'];
336
            case Ldap::ACCTNAME_FORM_BACKSLASH: // backslash style usernames, e.g. FOO\alice
337
                // @todo Not supported yet!
338
                throw new Exception('Backslash style not supported in LDAPGateway::getUsernameByEmail()!');
339
            case Ldap::ACCTNAME_FORM_PRINCIPAL: // principal style usernames, e.g. [email protected]
340
                if (empty($data['userprincipalname'])) {
341
                    throw new Exception('Could not extract canonical username: userprincipalname field missing');
342
                }
343
                return $data['userprincipalname'];
344
            default: // default to principal style
345
                if (empty($data['userprincipalname'])) {
346
                    throw new Exception('Could not extract canonical username: userprincipalname field missing');
347
                }
348
                return $data['userprincipalname'];
349
        }
350
    }
351
352
    /**
353
     * Changes user password via LDAP.
354
     *
355
     * Change password is different to administrative password reset in that it will respect the password
356
     * history policy. This is achieved by sending a remove followed by an add in one batch (which is different
357
     * to an ordinary attribute modification operation).
358
     *
359
     * @param string $dn Location to update the entry at.
360
     * @param string $password New password to set.
361
     * @param string $oldPassword Old password is needed to trigger a password change.
362
     * @throws \Zend\Ldap\Exception\LdapException
363
     * @throws Exception
364
     */
365
    public function changePassword($dn, $password, $oldPassword)
366
    {
367
        if (!function_exists('ldap_modify_batch')) {
368
            // Password change is unsupported - missing PHP API method.
369
            $this->resetPassword($dn, $password);
370
            return;
371
        }
372
373
        $modifications = [
374
            [
375
                'attrib'  => 'unicodePwd',
376
                'modtype' => LDAP_MODIFY_BATCH_REMOVE,
0 ignored issues
show
Bug introduced by
The constant SilverStripe\LDAP\Model\LDAP_MODIFY_BATCH_REMOVE was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
377
                'values'  => [iconv('UTF-8', 'UTF-16LE', sprintf('"%s"', $oldPassword))],
378
            ],
379
            [
380
                'attrib'  => 'unicodePwd',
381
                'modtype' => LDAP_MODIFY_BATCH_ADD,
0 ignored issues
show
Bug introduced by
The constant SilverStripe\LDAP\Model\LDAP_MODIFY_BATCH_ADD was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
382
                'values'  => [iconv('UTF-8', 'UTF-16LE', sprintf('"%s"', $password))],
383
            ],
384
        ];
385
        // Batch attribute operations are not supported by Zend_Ldap, use raw resource.
386
        $ldapConn = $this->ldap->getResource();
387
        ErrorHandler::start(E_WARNING);
388
        $succeeded = ldap_modify_batch($ldapConn, $dn, $modifications);
389
        ErrorHandler::stop();
390
        if (!$succeeded) {
391
            throw new Exception($this->getLastPasswordError());
392
        }
393
    }
394
395
    /**
396
     * Administrative password reset.
397
     *
398
     * This is different to password change - it does not require old password, but also
399
     * it does not respect password history policy setting.
400
     *
401
     * @param string $dn Location to update the entry at.
402
     * @param string $password New password to set.
403
     * @throws \Zend\Ldap\Exception\LdapException
404
     * @throws Exception
405
     */
406
    public function resetPassword($dn, $password)
407
    {
408
        try {
409
            $this->update(
410
                $dn,
411
                ['unicodePwd' => iconv('UTF-8', 'UTF-16LE', sprintf('"%s"', $password))]
412
            );
413
        } catch (LdapException $e) {
414
            throw new Exception($this->getLastPasswordError());
415
        }
416
    }
417
418
    /**
419
     * Updates an LDAP object.
420
     *
421
     * For this work you might need that LDAP connection is bind:ed with a user with
422
     * enough permissions to change attributes and that the LDAP connection is using
423
     * SSL/TLS. It depends on the server setup.
424
     *
425
     * If there are some errors, the underlying LDAP library should throw an Exception
426
     *
427
     * @param string $dn Location to update the entry at.
428
     * @param array $attributes An associative array of attributes.
429
     * @throws \Zend\Ldap\Exception\LdapException
430
     */
431
    public function update($dn, array $attributes)
432
    {
433
        $this->ldap->update($dn, $attributes);
434
    }
435
436
    /**
437
     * Deletes an LDAP object.
438
     *
439
     * @param string $dn Location of object to delete
440
     * @param bool $recursively Recursively delete nested objects?
441
     * @throws \Zend\Ldap\Exception\LdapException
442
     */
443
    public function delete($dn, $recursively = false)
444
    {
445
        $this->ldap->delete($dn, $recursively);
446
    }
447
448
    /**
449
     * Move an LDAP object from it's original DN location to another.
450
     * This effectively copies the object then deletes the original one.
451
     *
452
     * @param string $fromDn
453
     * @param string $toDn
454
     * @param bool $recursively Recursively move nested objects?
455
     */
456
    public function move($fromDn, $toDn, $recursively = false)
457
    {
458
        $this->ldap->move($fromDn, $toDn, $recursively);
459
    }
460
461
    /**
462
     * Add an LDAP object.
463
     *
464
     * For this work you might need that LDAP connection is bind:ed with a user with
465
     * enough permissions to change attributes and that the LDAP connection is using
466
     * SSL/TLS. It depends on the server setup.
467
     *
468
     * @param string $dn Location to add the entry at.
469
     * @param array $attributes An associative array of attributes.
470
     * @throws \Zend\Ldap\Exception\LdapException
471
     */
472
    public function add($dn, array $attributes)
473
    {
474
        $this->ldap->add($dn, $attributes);
475
    }
476
477
    /**
478
     * @return string
479
     */
480
    private function getLastPasswordError()
481
    {
482
        $defaultError = _t(
483
            'SilverStripe\\LDAP\\Authenticators\\LDAPAuthenticator.CANTCHANGEPASSWORD',
484
            'We couldn\'t change your password, please contact an administrator.'
485
        );
486
        $error = '';
487
        ldap_get_option($this->ldap->getResource(), LDAP_OPT_ERROR_STRING, $error);
488
489
        if (!$error) {
490
            return $defaultError;
491
        }
492
493
        // Try to parse the exception to get the error message to display to user, eg:
494
        // 0000052D: Constraint violation - check_password_restrictions: the password does not meet the complexity criteria!)
495
        // 0000052D: Constraint violation - check_password_restrictions: the password was already used (in history)!)
496
497
        // We are only interested in the explanatory message after the last colon.
498
        $message = preg_replace('/.*:/', '', $error);
499
        if ($error) {
500
            return ucfirst(trim($message));
501
        }
502
503
        return $defaultError;
504
    }
505
}
506