Completed
Pull Request — master (#105)
by Mateusz
11:29
created

LDAPService::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
eloc 2
nc 1
nop 0
1
<?php
2
3
namespace SilverStripe\ActiveDirectory\Services;
4
5
use Exception;
6
use Psr\Log\LoggerInterface;
7
use Psr\SimpleCache\CacheInterface;
8
use SilverStripe\ActiveDirectory\Model\LDAPGateway;
9
use SilverStripe\ActiveDirectory\Model\LDAPGroupMapping;
10
use SilverStripe\Assets\Image;
11
use SilverStripe\Assets\Filesystem;
12
use SilverStripe\Core\Config\Config;
13
use SilverStripe\Core\Config\Configurable;
14
use SilverStripe\Core\Flushable;
15
use SilverStripe\Core\Extensible;
16
use SilverStripe\Core\Injector\Injector;
17
use SilverStripe\Core\Injector\Injectable;
18
use SilverStripe\ORM\DB;
19
use SilverStripe\ORM\FieldType\DBDatetime;
20
use SilverStripe\ORM\ValidationException;
21
use SilverStripe\ORM\ValidationResult;
22
use SilverStripe\Security\Group;
23
use SilverStripe\Security\Member;
24
use SilverStripe\Security\RandomGenerator;
25
use Zend\Ldap\Ldap;
26
27
/**
28
 * Class LDAPService
29
 *
30
 * Provides LDAP operations expressed in terms of the SilverStripe domain.
31
 * All other modules should access LDAP through this class.
32
 *
33
 * This class builds on top of LDAPGateway's detailed code by adding:
34
 * - caching
35
 * - data aggregation and restructuring from multiple lower-level calls
36
 * - error handling
37
 *
38
 * LDAPService relies on Zend LDAP module's data structures for some parameters and some return values.
39
 *
40
 * @package activedirectory
41
 */
42
class LDAPService implements Flushable
43
{
44
    use Injectable;
45
    use Extensible;
46
    use Configurable;
47
48
    /**
49
     * @var array
50
     */
51
    private static $dependencies = [
0 ignored issues
show
Unused Code introduced by
The property $dependencies 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...
52
        'gateway' => '%$' . LDAPGateway::class
53
    ];
54
55
    /**
56
     * If configured, only user objects within these locations will be exposed to this service.
57
     *
58
     * @var array
59
     * @config
60
     */
61
    private static $users_search_locations = [];
0 ignored issues
show
Unused Code introduced by
The property $users_search_locations 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...
62
63
    /**
64
     * If configured, only group objects within these locations will be exposed to this service.
65
     * @var array
66
     *
67
     * @config
68
     */
69
    private static $groups_search_locations = [];
0 ignored issues
show
Unused Code introduced by
The property $groups_search_locations 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...
70
71
    /**
72
     * Location to create new users in (distinguished name).
73
     * @var string
74
     *
75
     * @config
76
     */
77
    private static $new_users_dn;
0 ignored issues
show
Unused Code introduced by
The property $new_users_dn 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...
78
79
    /**
80
     * Location to create new groups in (distinguished name).
81
     * @var string
82
     *
83
     * @config
84
     */
85
    private static $new_groups_dn;
0 ignored issues
show
Unused Code introduced by
The property $new_groups_dn 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...
86
87
    /**
88
     * @var array
89
     */
90
    private static $_cache_nested_groups = [];
91
92
    /**
93
     * If this is configured to a "Code" value of a {@link Group} in SilverStripe, the user will always
94
     * be added to this group's membership when imported, regardless of any sort of group mappings.
95
     *
96
     * @var string
97
     * @config
98
     */
99
    private static $default_group;
0 ignored issues
show
Unused Code introduced by
The property $default_group 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...
100
101
    /**
102
     * For samba4 directory, there is no way to enforce password history on password resets.
103
     * This only happens with changePassword (which requires the old password).
104
     * This works around it by making the old password up and setting it administratively.
105
     *
106
     * A cleaner fix would be to use the LDAP_SERVER_POLICY_HINTS_OID connection flag,
107
     * but it's not implemented in samba https://bugzilla.samba.org/show_bug.cgi?id=12020
108
     *
109
     * @var bool
110
     */
111
    private static $password_history_workaround = false;
0 ignored issues
show
Unused Code introduced by
The property $password_history_workaround 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...
112
113
    /**
114
     * Get the cache object used for LDAP results. Note that the default lifetime set here
115
     * is 8 hours, but you can change that by adding configuration:
116
     *
117
     * <code>
118
     * SilverStripe\Core\Injector\Injector:
119
     *   Psr\SimpleCache\CacheInterface.ldap:
120
     *     constructor:
121
     *       defaultLifetime: 3600 # time in seconds
122
     * </code>
123
     *
124
     * @return Psr\SimpleCache\CacheInterface
125
     */
126
    public static function get_cache()
127
    {
128
        return Injector::inst()->get(CacheInterface::class . '.ldap');
129
    }
130
131
    /**
132
     * Flushes out the LDAP results cache when flush=1 is called.
133
     */
134
    public static function flush()
135
    {
136
        /** @var CacheInterface $cache */
137
        $cache = self::get_cache();
138
        $cache->clear();
139
    }
140
141
    /**
142
     * @var LDAPGateway
143
     */
144
    public $gateway;
145
146
    public function __construct()
147
    {
148
        $this->constructExtensions();
149
    }
150
151
    /**
152
     * Setter for gateway. Useful for overriding the gateway with a fake for testing.
153
     * @var LDAPGateway
154
     */
155
    public function setGateway($gateway)
156
    {
157
        $this->gateway = $gateway;
158
    }
159
160
    /**
161
     * Checkes whether or not the service is enabled.
162
     *
163
     * @return bool
164
     */
165
    public function enabled()
166
    {
167
        $options = Config::inst()->get(LDAPGateway::class, 'options');
168
        return !empty($options);
169
    }
170
171
    /**
172
     * Authenticate the given username and password with LDAP.
173
     *
174
     * @param string $username
175
     * @param string $password
176
     *
177
     * @return array
178
     */
179
    public function authenticate($username, $password)
180
    {
181
        $result = $this->gateway->authenticate($username, $password);
182
        $messages = $result->getMessages();
183
184
        // all messages beyond the first one are for debugging and
185
        // not suitable to display to the user.
186
        foreach ($messages as $i => $message) {
187
            if ($i > 0) {
188
                $this->getLogger()->debug(str_replace("\n", "\n  ", $message));
189
            }
190
        }
191
192
        $message = $messages[0]; // first message is user readable, suitable for showing on login form
193
194
        // show better errors than the defaults for various status codes returned by LDAP
195 View Code Duplication
        if (!empty($messages[1]) && strpos($messages[1], 'NT_STATUS_ACCOUNT_LOCKED_OUT') !== false) {
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...
196
            $message = _t(
197
                'LDAPService.ACCOUNTLOCKEDOUT',
198
                'Your account has been temporarily locked because of too many failed login attempts. ' .
199
                'Please try again later.'
200
            );
201
        }
202 View Code Duplication
        if (!empty($messages[1]) && strpos($messages[1], 'NT_STATUS_LOGON_FAILURE') !== false) {
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...
203
            $message = _t(
204
                'LDAPService.INVALIDCREDENTIALS',
205
                'The provided details don\'t seem to be correct. Please try again.'
206
            );
207
        }
208
209
        return [
210
            'success' => $result->getCode() === 1,
211
            'identity' => $result->getIdentity(),
212
            'message' => $message
213
        ];
214
    }
215
216
    /**
217
     * Return all nodes (organizational units, containers, and domains) within the current base DN.
218
     *
219
     * @param boolean $cached Cache the results from AD, so that subsequent calls are faster. Enabled by default.
220
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
221
     * @return array
222
     */
223
    public function getNodes($cached = true, $attributes = [])
224
    {
225
        $cache = self::get_cache();
226
        $cacheKey = 'nodes' . md5(implode('', $attributes));
227
        $results = $cache->has($cacheKey);
228
229
        if (!$results || !$cached) {
230
            $results = [];
231
            $records = $this->gateway->getNodes(null, Ldap::SEARCH_SCOPE_SUB, $attributes);
232
            foreach ($records as $record) {
233
                $results[$record['dn']] = $record;
234
            }
235
236
            $cache->set($cacheKey, $results);
237
        }
238
239
        return $results;
240
    }
241
242
    /**
243
     * Return all AD groups in configured search locations, including all nested groups.
244
     * Uses groups_search_locations if defined, otherwise falls back to NULL, which tells LDAPGateway
245
     * to use the default baseDn defined in the connection.
246
     *
247
     * @param boolean $cached Cache the results from AD, so that subsequent calls are faster. Enabled by default.
248
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
249
     * @param string $indexBy Attribute to use as an index.
250
     * @return array
251
     */
252
    public function getGroups($cached = true, $attributes = [], $indexBy = 'dn')
253
    {
254
        $searchLocations = $this->config()->groups_search_locations ?: [null];
255
        $cache = self::get_cache();
256
        $cacheKey = 'groups' . md5(implode('', array_merge($searchLocations, $attributes)));
257
        $results = $cache->has($cacheKey);
258
259
        if (!$results || !$cached) {
260
            $results = [];
261 View Code Duplication
            foreach ($searchLocations as $searchLocation) {
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...
262
                $records = $this->gateway->getGroups($searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
263
                if (!$records) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $records of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
264
                    continue;
265
                }
266
267
                foreach ($records as $record) {
268
                    $results[$record[$indexBy]] = $record;
269
                }
270
            }
271
272
            $cache->set($cacheKey, $results);
273
        }
274
275
        if ($cached && $results === true) {
276
            $results = $cache->get($cacheKey);
277
        }
278
279
        return $results;
280
    }
281
282
    /**
283
     * Return all member groups (and members of those, recursively) underneath a specific group DN.
284
     * Note that these get cached in-memory per-request for performance to avoid re-querying for the same results.
285
     *
286
     * @param string $dn
287
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
288
     * @return array
289
     */
290
    public function getNestedGroups($dn, $attributes = [])
291
    {
292
        if (isset(self::$_cache_nested_groups[$dn])) {
293
            return self::$_cache_nested_groups[$dn];
294
        }
295
296
        $searchLocations = $this->config()->groups_search_locations ?: [null];
297
        $results = [];
298 View Code Duplication
        foreach ($searchLocations as $searchLocation) {
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...
299
            $records = $this->gateway->getNestedGroups($dn, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
300
            foreach ($records as $record) {
301
                $results[$record['dn']] = $record;
302
            }
303
        }
304
305
        self::$_cache_nested_groups[$dn] = $results;
306
        return $results;
307
    }
308
309
    /**
310
     * Get a particular AD group's data given a GUID.
311
     *
312
     * @param string $guid
313
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
314
     * @return array
315
     */
316 View Code Duplication
    public function getGroupByGUID($guid, $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...
317
    {
318
        $searchLocations = $this->config()->groups_search_locations ?: [null];
319
        foreach ($searchLocations as $searchLocation) {
320
            $records = $this->gateway->getGroupByGUID($guid, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
321
            if ($records) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $records of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
322
                return $records[0];
323
            }
324
        }
325
    }
326
327
    /**
328
     * Get a particular AD group's data given a DN.
329
     *
330
     * @param string $dn
331
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
332
     * @return array
333
     */
334 View Code Duplication
    public function getGroupByDN($dn, $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...
335
    {
336
        $searchLocations = $this->config()->groups_search_locations ?: [null];
337
        foreach ($searchLocations as $searchLocation) {
338
            $records = $this->gateway->getGroupByDN($dn, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
339
            if ($records) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $records of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
340
                return $records[0];
341
            }
342
        }
343
    }
344
345
    /**
346
     * Return all AD users in configured search locations, including all users in nested groups.
347
     * Uses users_search_locations if defined, otherwise falls back to NULL, which tells LDAPGateway
348
     * to use the default baseDn defined in the connection.
349
     *
350
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
351
     * @return array
352
     */
353
    public function getUsers($attributes = [])
354
    {
355
        $searchLocations = $this->config()->users_search_locations ?: [null];
356
        $results = [];
357
358 View Code Duplication
        foreach ($searchLocations as $searchLocation) {
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...
359
            $records = $this->gateway->getUsers($searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
360
            if (!$records) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $records of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
361
                continue;
362
            }
363
364
            foreach ($records as $record) {
365
                $results[$record['objectguid']] = $record;
366
            }
367
        }
368
369
        return $results;
370
    }
371
372
    /**
373
     * Get a specific AD user's data given a GUID.
374
     *
375
     * @param string $guid
376
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
377
     * @return array
378
     */
379 View Code Duplication
    public function getUserByGUID($guid, $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...
380
    {
381
        $searchLocations = $this->config()->users_search_locations ?: [null];
382
        foreach ($searchLocations as $searchLocation) {
383
            $records = $this->gateway->getUserByGUID($guid, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
384
            if ($records) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $records of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
385
                return $records[0];
386
            }
387
        }
388
    }
389
390
    /**
391
     * Get a specific AD user's data given a DN.
392
     *
393
     * @param string $dn
394
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
395
     *
396
     * @return array
397
     */
398 View Code Duplication
    public function getUserByDN($dn, $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...
399
    {
400
        $searchLocations = $this->config()->users_search_locations ?: [null];
401
        foreach ($searchLocations as $searchLocation) {
402
            $records = $this->gateway->getUserByDN($dn, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
403
            if ($records) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $records of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
404
                return $records[0];
405
            }
406
        }
407
    }
408
409
    /**
410
     * Get a specific user's data given an email.
411
     *
412
     * @param string $email
413
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
414
     * @return array
415
     */
416 View Code Duplication
    public function getUserByEmail($email, $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...
417
    {
418
        $searchLocations = $this->config()->users_search_locations ?: [null];
419
        foreach ($searchLocations as $searchLocation) {
420
            $records = $this->gateway->getUserByEmail($email, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
421
            if ($records) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $records of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
422
                return $records[0];
423
            }
424
        }
425
    }
426
427
    /**
428
     * Get a specific user's data given a username.
429
     *
430
     * @param string $username
431
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
432
     * @return array
433
     */
434 View Code Duplication
    public function getUserByUsername($username, $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...
435
    {
436
        $searchLocations = $this->config()->users_search_locations ?: [null];
437
        foreach ($searchLocations as $searchLocation) {
438
            $records = $this->gateway->getUserByUsername($username, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
439
            if ($records) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $records of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
440
                return $records[0];
441
            }
442
        }
443
    }
444
445
    /**
446
     * Get a username for an email.
447
     *
448
     * @param string $email
449
     * @return string|null
450
     */
451
    public function getUsernameByEmail($email)
452
    {
453
        $data = $this->getUserByEmail($email);
454
        if (empty($data)) {
455
            return null;
456
        }
457
458
        return $this->gateway->getCanonicalUsername($data);
459
    }
460
461
    /**
462
     * Given a group DN, get the group membership data in LDAP.
463
     *
464
     * @param string $dn
465
     * @return array
466
     */
467
    public function getLDAPGroupMembers($dn)
468
    {
469
        $groupObj = Group::get()->filter('DN', $dn)->first();
470
        $groupData = $this->getGroupByGUID($groupObj->GUID);
471
        $members = !empty($groupData['member']) ? $groupData['member'] : [];
472
        // If a user belongs to a single group, this comes through as a string.
473
        // Normalise to a array so it's consistent.
474
        if ($members && is_string($members)) {
475
            $members = [$members];
476
        }
477
478
        return $members;
479
    }
480
481
    /**
482
     * Update the current Member record with data from LDAP.
483
     *
484
     * It's allowed to pass an unwritten Member record here, because it's not always possible to satisfy
485
     * field constraints without importing data from LDAP (for example if the application requires Username
486
     * through a Validator). Even though unwritten, it still must have the GUID set.
487
     *
488
     * Constraints:
489
     * - GUID of the member must have already been set, for integrity reasons we don't allow it to change here.
490
     *
491
     * @param Member
492
     * @param array|null $data If passed, this is pre-existing AD attribute data to update the Member with.
493
     *            If not given, the data will be looked up by the user's GUID.
494
     * @return bool
495
     */
496
    public function updateMemberFromLDAP(Member $member, $data = null)
497
    {
498
        if (!$this->enabled()) {
499
            return false;
500
        }
501
502
        if (!$member->GUID) {
503
            $this->getLogger()->warn(sprintf('Cannot update Member ID %s, GUID not set', $member->ID));
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Psr\Log\LoggerInterface as the method warn() does only exist in the following implementations of said interface: Monolog\Logger.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
504
            return false;
505
        }
506
507
        if (!$data) {
508
            $data = $this->getUserByGUID($member->GUID);
509
            if (!$data) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $data of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
510
                $this->getLogger()->warn(sprintf('Could not retrieve data for user. GUID: %s', $member->GUID));
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Psr\Log\LoggerInterface as the method warn() does only exist in the following implementations of said interface: Monolog\Logger.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
511
                return false;
512
            }
513
        }
514
515
        $member->IsExpired = ($data['useraccountcontrol'] & 2) == 2;
516
        $member->LastSynced = (string)DBDatetime::now();
517
518
        foreach ($member->config()->ldap_field_mappings as $attribute => $field) {
519
            if (!isset($data[$attribute])) {
520
                $this->getLogger()->notice(
521
                    sprintf(
522
                        'Attribute %s configured in Member.ldap_field_mappings, but no available attribute in AD data (GUID: %s, Member ID: %s)',
523
                        $attribute,
524
                        $data['objectguid'],
525
                        $member->ID
526
                    )
527
                );
528
529
                continue;
530
            }
531
532
            if ($attribute == 'thumbnailphoto') {
533
                $imageClass = $member->getRelationClass($field);
534
                if ($imageClass !== Image::class
535
                    && !is_subclass_of($imageClass, Image::class)
0 ignored issues
show
Bug introduced by
Due to PHP Bug #53727, is_subclass_of might return inconsistent results on some PHP versions if \SilverStripe\Assets\Image::class can be an interface. If so, you could instead use ReflectionClass::implementsInterface.
Loading history...
536
                ) {
537
                    $this->getLogger()->warn(
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Psr\Log\LoggerInterface as the method warn() does only exist in the following implementations of said interface: Monolog\Logger.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
538
                        sprintf(
539
                            'Member field %s configured for thumbnailphoto AD attribute, but it isn\'t a valid relation to an Image class',
540
                            $field
541
                        )
542
                    );
543
544
                    continue;
545
                }
546
547
                $filename = sprintf('thumbnailphoto-%s.jpg', $data['samaccountname']);
548
                $path = ASSETS_DIR . '/' . $member->config()->ldap_thumbnail_path;
549
                $absPath = BASE_PATH . '/' . $path;
550
                if (!file_exists($absPath)) {
551
                    Filesystem::makeFolder($absPath);
552
                }
553
554
                // remove existing record if it exists
555
                $existingObj = $member->getComponent($field);
556
                if ($existingObj && $existingObj->exists()) {
557
                    $existingObj->delete();
558
                }
559
560
                // The image data is provided in raw binary.
561
                file_put_contents($absPath . '/' . $filename, $data[$attribute]);
562
                $record = new $imageClass();
563
                $record->Name = $filename;
564
                $record->Filename = $path . '/' . $filename;
565
                $record->write();
566
567
                $relationField = $field . 'ID';
568
                $member->{$relationField} = $record->ID;
569
            } else {
570
                $member->$field = $data[$attribute];
571
            }
572
        }
573
574
        // if a default group was configured, ensure the user is in that group
575
        if ($this->config()->default_group) {
576
            $group = Group::get()->filter('Code', $this->config()->default_group)->limit(1)->first();
577
            if (!($group && $group->exists())) {
578
                $this->getLogger()->warn(
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Psr\Log\LoggerInterface as the method warn() does only exist in the following implementations of said interface: Monolog\Logger.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
579
                    sprintf(
580
                        'LDAPService.default_group misconfiguration! There is no such group with Code = \'%s\'',
581
                        $this->config()->default_group
582
                    )
583
                );
584
            } else {
585
                $group->Members()->add($member, [
586
                    'IsImportedFromLDAP' => '1'
587
                ]);
588
            }
589
        }
590
591
        // this is to keep track of which groups the user gets mapped to
592
        // and we'll use that later to remove them from any groups that they're no longer mapped to
593
        $mappedGroupIDs = [];
594
595
        // Good moment to write - need to ensure Member has an ID before manipulating Groups
596
        // (we permit passing an unwritten Member to this function).
597
        // This will throw an exception if there are two distinct GUIDs with the same email address.
598
        // We are happy with a raw 500 here at this stage.
599
        $member->write();
600
601
        // ensure the user is in any mapped groups
602
        if (isset($data['memberof'])) {
603
            $ldapGroups = is_array($data['memberof']) ? $data['memberof'] : [$data['memberof']];
604
            foreach ($ldapGroups as $groupDN) {
605
                foreach (LDAPGroupMapping::get() as $mapping) {
606
                    if (!$mapping->DN) {
607
                        $this->getLogger()->warn(
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Psr\Log\LoggerInterface as the method warn() does only exist in the following implementations of said interface: Monolog\Logger.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
608
                            sprintf(
609
                                'LDAPGroupMapping ID %s is missing DN field. Skipping',
610
                                $mapping->ID
611
                            )
612
                        );
613
                        continue;
614
                    }
615
616
                    // the user is a direct member of group with a mapping, add them to the SS group.
617 View Code Duplication
                    if ($mapping->DN == $groupDN) {
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...
618
                        $group = $mapping->Group();
619
                        if ($group && $group->exists()) {
620
                            $group->Members()->add($member, [
621
                                'IsImportedFromLDAP' => '1'
622
                            ]);
623
                            $mappedGroupIDs[] = $mapping->GroupID;
624
                        }
625
                    }
626
627
                    // the user *might* be a member of a nested group provided the scope of the mapping
628
                    // is to include the entire subtree. Check all those mappings and find the LDAP child groups
629
                    // to see if they are a member of one of those. If they are, add them to the SS group
630
                    if ($mapping->Scope == 'Subtree') {
631
                        $childGroups = $this->getNestedGroups($mapping->DN, ['dn']);
632
                        if (!$childGroups) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $childGroups of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
633
                            continue;
634
                        }
635
636
                        foreach ($childGroups as $childGroupDN => $childGroupRecord) {
637 View Code Duplication
                            if ($childGroupDN == $groupDN) {
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...
638
                                $group = $mapping->Group();
639
                                if ($group && $group->exists()) {
640
                                    $group->Members()->add($member, [
641
                                        'IsImportedFromLDAP' => '1'
642
                                    ]);
643
                                    $mappedGroupIDs[] = $mapping->GroupID;
644
                                }
645
                            }
646
                        }
647
                    }
648
                }
649
            }
650
        }
651
652
        // remove the user from any previously mapped groups, where the mapping has since been removed
653
        $groupRecords = DB::query(
654
            sprintf(
655
                'SELECT "GroupID" FROM "Group_Members" WHERE "IsImportedFromLDAP" = 1 AND "MemberID" = %s',
656
                $member->ID
657
            )
658
        );
659
660
        foreach ($groupRecords as $groupRecord) {
661
            if (!in_array($groupRecord['GroupID'], $mappedGroupIDs)) {
662
                $group = Group::get()->byId($groupRecord['GroupID']);
663
                // Some groups may no longer exist. SilverStripe does not clean up join tables.
664
                if ($group) {
665
                    $group->Members()->remove($member);
666
                }
667
            }
668
        }
669
    }
670
671
    /**
672
     * Sync a specific Group by updating it with LDAP data.
673
     *
674
     * @param Group $group An existing Group or a new Group object
675
     * @param array $data LDAP group object data
676
     *
677
     * @return bool
678
     */
679
    public function updateGroupFromLDAP(Group $group, $data)
680
    {
681
        if (!$this->enabled()) {
682
            return false;
683
        }
684
685
        // Synchronise specific guaranteed fields.
686
        $group->Code = $data['samaccountname'];
687
        $group->Title = $data['samaccountname'];
688
        if (!empty($data['description'])) {
689
            $group->Description = $data['description'];
690
        }
691
        $group->DN = $data['dn'];
692
        $group->LastSynced = (string)DBDatetime::now();
693
        $group->write();
694
695
        // Mappings on this group are automatically maintained to contain just the group's DN.
696
        // First, scan through existing mappings and remove ones that are not matching (in case the group moved).
697
        $hasCorrectMapping = false;
698
        foreach ($group->LDAPGroupMappings() as $mapping) {
699
            if ($mapping->DN === $data['dn']) {
700
                // This is the correct mapping we want to retain.
701
                $hasCorrectMapping = true;
702
            } else {
703
                $mapping->delete();
704
            }
705
        }
706
707
        // Second, if the main mapping was not found, add it in.
708
        if (!$hasCorrectMapping) {
709
            $mapping = new LDAPGroupMapping();
710
            $mapping->DN = $data['dn'];
0 ignored issues
show
Documentation introduced by
The property DN does not exist on object<SilverStripe\Acti...Model\LDAPGroupMapping>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
711
            $mapping->write();
712
            $group->LDAPGroupMappings()->add($mapping);
713
        }
714
    }
715
716
    /**
717
     * Creates a new LDAP user from the passed Member record.
718
     * Note that the Member record must have a non-empty Username field for this to work.
719
     *
720
     * @param Member $member
721
     * @throws ValidationException
722
     * @throws Exception
723
     */
724
    public function createLDAPUser(Member $member)
725
    {
726
        if (!$this->enabled()) {
727
            return;
728
        }
729
        if (empty($member->Username)) {
730
            throw new ValidationException('Member missing Username. Cannot create LDAP user');
731
        }
732
        if (!$this->config()->new_users_dn) {
733
            throw new Exception('LDAPService::new_users_dn must be configured to create LDAP users');
734
        }
735
736
        // Normalise username to lowercase to ensure we don't have duplicates of different cases
737
        $member->Username = strtolower($member->Username);
738
739
        // Create user in LDAP using available information.
740
        $dn = sprintf('CN=%s,%s', $member->Username, $this->config()->new_users_dn);
741
742
        try {
743
            $this->add($dn, [
744
                'objectclass' => 'user',
745
                'cn' => $member->Username,
746
                'accountexpires' => '9223372036854775807',
747
                'useraccountcontrol' => '66048',
748
                'userprincipalname' => sprintf(
749
                    '%s@%s',
750
                    $member->Username,
751
                    $this->gateway->config()->options['accountDomainName']
752
                ),
753
            ]);
754
        } catch (Exception $e) {
755
            throw new ValidationException('LDAP synchronisation failure: ' . $e->getMessage());
756
        }
757
758
        $user = $this->getUserByUsername($member->Username);
759
        if (empty($user['objectguid'])) {
760
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
761
        }
762
763
        // Creation was successful, mark the user as LDAP managed by setting the GUID.
764
        $member->GUID = $user['objectguid'];
765
    }
766
767
    /**
768
     * Creates a new LDAP group from the passed Group record.
769
     *
770
     * @param Group $group
771
     * @throws ValidationException
772
     */
773
    public function createLDAPGroup(Group $group)
774
    {
775
        if (!$this->enabled()) {
776
            return;
777
        }
778
        if (empty($group->Title)) {
779
            throw new ValidationException('Group missing Title. Cannot create LDAP group');
780
        }
781
        if (!$this->config()->new_groups_dn) {
782
            throw new Exception('LDAPService::new_groups_dn must be configured to create LDAP groups');
783
        }
784
785
        // LDAP isn't really meant to distinguish between a Title and Code. Squash them.
786
        $group->Code = $group->Title;
787
788
        $dn = sprintf('CN=%s,%s', $group->Title, $this->config()->new_groups_dn);
789
        try {
790
            $this->add($dn, [
791
                'objectclass' => 'group',
792
                'cn' => $group->Title,
793
                'name' => $group->Title,
794
                'samaccountname' => $group->Title,
795
                'description' => $group->Description,
796
                'distinguishedname' => $dn
797
            ]);
798
        } catch (Exception $e) {
799
            throw new ValidationException('LDAP group creation failure: ' . $e->getMessage());
800
        }
801
802
        $data = $this->getGroupByDN($dn);
803
        if (empty($data['objectguid'])) {
804
            throw new ValidationException(
805
                new ValidationResult(
806
                    false,
807
                    'LDAP group creation failure: group might have been created in LDAP. GUID is missing.'
808
                )
809
            );
810
        }
811
812
        // Creation was successful, mark the group as LDAP managed by setting the GUID.
813
        $group->GUID = $data['objectguid'];
814
        $group->DN = $data['dn'];
815
    }
816
817
    /**
818
     * Update the Member data back to the corresponding LDAP user object.
819
     *
820
     * @param Member $member
821
     * @throws ValidationException
822
     */
823
    public function updateLDAPFromMember(Member $member)
824
    {
825
        if (!$this->enabled()) {
826
            return;
827
        }
828
        if (!$member->GUID) {
829
            throw new ValidationException('Member missing GUID. Cannot update LDAP user');
830
        }
831
832
        $data = $this->getUserByGUID($member->GUID);
833
        if (empty($data['objectguid'])) {
834
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
835
        }
836
837
        if (empty($member->Username)) {
838
            throw new ValidationException('Member missing Username. Cannot update LDAP user');
839
        }
840
841
        $dn = $data['distinguishedname'];
842
843
        // Normalise username to lowercase to ensure we don't have duplicates of different cases
844
        $member->Username = strtolower($member->Username);
845
846
        try {
847
            // If the common name (cn) has changed, we need to ensure they've been moved
848
            // to the new DN, to avoid any clashes between user objects.
849
            if ($data['cn'] != $member->Username) {
850
                $newDn = sprintf('CN=%s,%s', $member->Username, preg_replace('/^CN=(.+?),/', '', $dn));
851
                $this->move($dn, $newDn);
852
                $dn = $newDn;
853
            }
854
        } catch (Exception $e) {
855
            throw new ValidationException('LDAP move failure: '.$e->getMessage());
856
        }
857
858
        try {
859
            $attributes = [
860
                'displayname' => sprintf('%s %s', $member->FirstName, $member->Surname),
861
                'name' => sprintf('%s %s', $member->FirstName, $member->Surname),
862
                'userprincipalname' => sprintf(
863
                    '%s@%s',
864
                    $member->Username,
865
                    $this->gateway->config()->options['accountDomainName']
866
                ),
867
            ];
868
            foreach ($member->config()->ldap_field_mappings as $attribute => $field) {
869
                $relationClass = $member->getRelationClass($field);
870
                if ($relationClass) {
0 ignored issues
show
Unused Code introduced by
This if statement is empty and can be removed.

This check looks for the bodies of if statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These if bodies can be removed. If you have an empty if but statements in the else branch, consider inverting the condition.

if (rand(1, 6) > 3) {
//print "Check failed";
} else {
    print "Check succeeded";
}

could be turned into

if (rand(1, 6) <= 3) {
    print "Check succeeded";
}

This is much more concise to read.

Loading history...
871
                    // todo no support for writing back relations yet.
872
                } else {
873
                    $attributes[$attribute] = $member->$field;
874
                }
875
            }
876
877
            $this->update($dn, $attributes);
878
        } catch (Exception $e) {
879
            throw new ValidationException('LDAP synchronisation failure: '.$e->getMessage());
880
        }
881
    }
882
883
    /**
884
     * Ensure the user belongs to the correct groups in LDAP from their membership
885
     * to local LDAP mapped SilverStripe groups.
886
     *
887
     * This also removes them from LDAP groups if they've been taken out of one.
888
     * It will not affect group membership of non-mapped groups, so it will
889
     * not touch such internal AD groups like "Domain Users".
890
     *
891
     * @param Member $member
892
     * @throws ValidationException
893
     */
894
    public function updateLDAPGroupsForMember(Member $member)
895
    {
896
        if (!$this->enabled()) {
897
            return;
898
        }
899
        if (!$member->GUID) {
900
            throw new ValidationException('Member missing GUID. Cannot update LDAP user');
901
        }
902
903
        $addGroups = [];
904
        $removeGroups = [];
905
906
        $user = $this->getUserByGUID($member->GUID);
907
        if (empty($user['objectguid'])) {
908
            throw new ValidationException('LDAP update failure: user missing GUID');
909
        }
910
911
        // If a user belongs to a single group, this comes through as a string.
912
        // Normalise to a array so it's consistent.
913
        $existingGroups = !empty($user['memberof']) ? $user['memberof'] : [];
914
        if ($existingGroups && is_string($existingGroups)) {
915
            $existingGroups = [$existingGroups];
916
        }
917
918
        foreach ($member->Groups() as $group) {
919
            if (!$group->GUID) {
920
                continue;
921
            }
922
923
            // mark this group as something we need to ensure the user belongs to in LDAP.
924
            $addGroups[] = $group->DN;
925
        }
926
927
        // Which existing LDAP groups are not in the add groups? We'll check these groups to
928
        // see if the user should be removed from any of them.
929
        $remainingGroups = array_diff($existingGroups, $addGroups);
930
931
        foreach ($remainingGroups as $groupDn) {
932
            // We only want to be removing groups we have a local Group mapped to. Removing
933
            // membership for anything else would be bad!
934
            $group = Group::get()->filter('DN', $groupDn)->first();
935
            if (!$group || !$group->exists()) {
936
                continue;
937
            }
938
939
            // this group should be removed from the user's memberof attribute, as it's been removed.
940
            $removeGroups[] = $groupDn;
941
        }
942
943
        // go through the groups we want the user to be in and ensure they're in them.
944
        foreach ($addGroups as $groupDn) {
945
            $this->addLDAPUserToGroup($user['distinguishedname'], $groupDn);
946
        }
947
948
        // go through the groups we _don't_ want the user to be in and ensure they're taken out of them.
949
        foreach ($removeGroups as $groupDn) {
950
            $members = $this->getLDAPGroupMembers($groupDn);
951
952
            // remove the user from the members data.
953
            if (in_array($user['distinguishedname'], $members)) {
954
                foreach ($members as $i => $dn) {
955
                    if ($dn == $user['distinguishedname']) {
956
                        unset($members[$i]);
957
                    }
958
                }
959
            }
960
961
            try {
962
                $this->update($groupDn, ['member' => $members]);
963
            } catch (Exception $e) {
964
                throw new ValidationException('LDAP group membership remove failure: ' . $e->getMessage());
965
            }
966
        }
967
    }
968
969
    /**
970
     * Add LDAP user by DN to LDAP group.
971
     *
972
     * @param string $userDn
973
     * @param string $groupDn
974
     * @throws Exception
975
     */
976
    public function addLDAPUserToGroup($userDn, $groupDn)
977
    {
978
        $members = $this->getLDAPGroupMembers($groupDn);
979
980
        // this user is already in the group, no need to do anything.
981
        if (in_array($userDn, $members)) {
982
            return;
983
        }
984
985
        $members[] = $userDn;
986
987
        try {
988
            $this->update($groupDn, ['member' => $members]);
989
        } catch (Exception $e) {
990
            throw new ValidationException('LDAP group membership add failure: ' . $e->getMessage());
991
        }
992
    }
993
994
    /**
995
     * Change a members password on the AD. Works with ActiveDirectory compatible services that saves the
996
     * password in the `unicodePwd` attribute.
997
     *
998
     * @todo Use the Zend\Ldap\Attribute::setPassword functionality to create a password in
999
     * an abstract way, so it works on other LDAP directories, not just Active Directory.
1000
     *
1001
     * Ensure that the LDAP bind:ed user can change passwords and that the connection is secure.
1002
     *
1003
     * @param Member $member
1004
     * @param string $password
1005
     * @param string|null $oldPassword Supply old password to perform a password change (as opposed to password reset)
1006
     * @return ValidationResult
1007
     */
1008
    public function setPassword(Member $member, $password, $oldPassword = null)
1009
    {
1010
        $validationResult = ValidationResult::create();
1011
1012
        $this->extend('onBeforeSetPassword', $member, $password, $validationResult);
1013
1014
        if (!$member->GUID) {
1015
            $this->getLogger()->warn(sprintf('Cannot update Member ID %s, GUID not set', $member->ID));
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Psr\Log\LoggerInterface as the method warn() does only exist in the following implementations of said interface: Monolog\Logger.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
1016
            $validationResult->addError(
1017
                _t(
1018
                    'LDAPAuthenticator.NOUSER',
1019
                    'Your account hasn\'t been setup properly, please contact an administrator.'
1020
                )
1021
            );
1022
            return $validationResult;
1023
        }
1024
1025
        $userData = $this->getUserByGUID($member->GUID);
1026
        if (empty($userData['distinguishedname'])) {
1027
            $validationResult->addError(
1028
                _t(
1029
                    'LDAPAuthenticator.NOUSER',
1030
                    'Your account hasn\'t been setup properly, please contact an administrator.'
1031
                )
1032
            );
1033
            return $validationResult;
1034
        }
1035
1036
        try {
1037
            if (!empty($oldPassword)) {
1038
                $this->gateway->changePassword($userData['distinguishedname'], $password, $oldPassword);
1039
            } elseif ($this->config()->password_history_workaround) {
1040
                $this->passwordHistoryWorkaround($userData['distinguishedname'], $password);
1041
            } else {
1042
                $this->gateway->resetPassword($userData['distinguishedname'], $password);
1043
            }
1044
            $this->extend('onAfterSetPassword', $member, $password, $validationResult);
1045
        } catch (Exception $e) {
1046
            $validationResult->addError($e->getMessage());
1047
        }
1048
1049
        return $validationResult;
1050
    }
1051
1052
    /**
1053
     * Delete an LDAP user mapped to the Member record
1054
     * @param Member $member
1055
     * @throws ValidationException
1056
     */
1057
    public function deleteLDAPMember(Member $member)
1058
    {
1059
        if (!$this->enabled()) {
1060
            return;
1061
        }
1062
        if (!$member->GUID) {
1063
            throw new ValidationException('Member missing GUID. Cannot delete LDAP user');
1064
        }
1065
        $data = $this->getUserByGUID($member->GUID);
1066
        if (empty($data['distinguishedname'])) {
1067
            throw new ValidationException('LDAP delete failure: could not find distinguishedname attribute');
1068
        }
1069
1070
        try {
1071
            $this->delete($data['distinguishedname']);
1072
        } catch (Exception $e) {
1073
            throw new ValidationException('LDAP delete user failed: ' . $e->getMessage());
1074
        }
1075
    }
1076
1077
    /**
1078
     * A simple proxy to LDAP update operation.
1079
     *
1080
     * @param string $dn Location to add the entry at.
1081
     * @param array $attributes A simple associative array of attributes.
1082
     */
1083
    public function update($dn, array $attributes)
1084
    {
1085
        $this->gateway->update($dn, $attributes);
1086
    }
1087
1088
    /**
1089
     * A simple proxy to LDAP delete operation.
1090
     *
1091
     * @param string $dn Location of object to delete
1092
     * @param bool $recursively Recursively delete nested objects?
1093
     */
1094
    public function delete($dn, $recursively = false)
1095
    {
1096
        $this->gateway->delete($dn, $recursively);
1097
    }
1098
1099
    /**
1100
     * A simple proxy to LDAP copy/delete operation.
1101
     *
1102
     * @param string $fromDn
1103
     * @param string $toDn
1104
     * @param bool $recursively Recursively move nested objects?
1105
     */
1106
    public function move($fromDn, $toDn, $recursively = false)
1107
    {
1108
        $this->gateway->move($fromDn, $toDn, $recursively);
1109
    }
1110
1111
    /**
1112
     * A simple proxy to LDAP add operation.
1113
     *
1114
     * @param string $dn Location to add the entry at.
1115
     * @param array $attributes A simple associative array of attributes.
1116
     */
1117
    public function add($dn, array $attributes)
1118
    {
1119
        $this->gateway->add($dn, $attributes);
1120
    }
1121
1122
    /**
1123
     * @param string $dn Distinguished name of the user
1124
     * @param string $password New password.
1125
     * @throws Exception
1126
     */
1127
    private function passwordHistoryWorkaround($dn, $password)
1128
    {
1129
        $generator = new RandomGenerator();
1130
        // 'Aa1' is there to satisfy the complexity criterion.
1131
        $tempPassword = sprintf('Aa1%s', substr($generator->randomToken('sha1'), 0, 21));
1132
        $this->gateway->resetPassword($dn, $tempPassword);
1133
        $this->gateway->changePassword($dn, $password, $tempPassword);
1134
    }
1135
1136
    /**
1137
     * Get a logger
1138
     *
1139
     * @return LoggerInterface
1140
     */
1141
    public function getLogger()
1142
    {
1143
        return Injector::inst()->get(LoggerInterface::class);
1144
    }
1145
}
1146