Completed
Push — master ( fa4239...ccc086 )
by Mateusz
02:49
created

LDAPService::updateLDAPFromMember()   C

Complexity

Conditions 10
Paths 25

Size

Total Lines 59
Code Lines 35

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 1 Features 0
Metric Value
c 2
b 1
f 0
dl 0
loc 59
rs 6.5919
cc 10
eloc 35
nc 25
nop 1

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
/**
3
 * Class LDAPService
4
 *
5
 * Provides LDAP operations expressed in terms of the SilverStripe domain.
6
 * All other modules should access LDAP through this class.
7
 *
8
 * This class builds on top of LDAPGateway's detailed code by adding:
9
 * - caching
10
 * - data aggregation and restructuring from multiple lower-level calls
11
 * - error handling
12
 *
13
 * LDAPService relies on Zend LDAP module's data structures for some parameters and some return values.
14
 */
15
class LDAPService extends Object implements Flushable
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class must be in a namespace of at least one level to avoid collisions.

You can fix this by adding a namespace to your class:

namespace YourVendor;

class YourClass { }

When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.

Loading history...
16
{
17
    /**
18
     * @var array
19
     */
20
    private static $dependencies = array(
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...
21
        'gateway' => '%$LDAPGateway'
22
    );
23
24
    /**
25
     * If configured, only user objects within these locations will be exposed to this service.
26
     *
27
     * @var array
28
     * @config
29
     */
30
    private static $users_search_locations = array();
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...
31
32
    /**
33
     * If configured, only group objects within these locations will be exposed to this service.
34
     * @var array
35
     *
36
     * @config
37
     */
38
    private static $groups_search_locations = array();
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...
39
40
    /**
41
     * Location to create new users in (distinguished name).
42
     * @var string
43
     *
44
     * @config
45
     */
46
    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...
47
48
    /**
49
     * @var array
50
     */
51
    private static $_cache_nested_groups = array();
52
53
    /**
54
     * If this is configured to a "Code" value of a {@link Group} in SilverStripe, the user will always
55
     * be added to this group's membership when imported, regardless of any sort of group mappings.
56
     *
57
     * @var string
58
     * @config
59
     */
60
    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...
61
62
    /**
63
     * Get the cache objecgt used for LDAP results. Note that the default lifetime set here
64
     * is 8 hours, but you can change that by calling SS_Cache::set_lifetime('ldap', <lifetime in seconds>)
65
     *
66
     * @return Zend_Cache_Frontend
67
     */
68
    public static function get_cache()
69
    {
70
        return SS_Cache::factory('ldap', 'Output', array(
71
            'automatic_serialization' => true,
72
            'lifetime' => 28800
73
        ));
74
    }
75
76
    /**
77
     * Flushes out the LDAP results cache when flush=1 is called.
78
     */
79
    public static function flush()
80
    {
81
        $cache = self::get_cache();
82
        $cache->clean(Zend_Cache::CLEANING_MODE_ALL);
83
    }
84
85
    /**
86
     * @var LDAPGateway
87
     */
88
    public $gateway;
89
90
    /**
91
     * Setter for gateway. Useful for overriding the gateway with a fake for testing.
92
     * @var LDAPGateway
93
     */
94
    public function setGateway($gateway)
95
    {
96
        $this->gateway = $gateway;
97
    }
98
99
    /**
100
     * Checkes whether or not the service is enabled.
101
     *
102
     * @return bool
103
     */
104
    public function enabled()
105
    {
106
        $options = Config::inst()->get('LDAPGateway', 'options');
107
        return !empty($options);
108
    }
109
110
    /**
111
     * Authenticate the given username and password with LDAP.
112
     *
113
     * @param string $username
114
     * @param string $password
115
     *
116
     * @return array
117
     */
118
    public function authenticate($username, $password)
119
    {
120
        $result = $this->gateway->authenticate($username, $password);
121
        $messages = $result->getMessages();
122
123
        // all messages beyond the first one are for debugging and
124
        // not suitable to display to the user.
125
        foreach ($messages as $i => $message) {
126
            if ($i > 0) {
127
                SS_Log::log(str_replace("\n", "\n  ", $message), SS_Log::DEBUG);
128
            }
129
        }
130
131
        return array(
132
            'success' => $result->getCode() === 1,
133
            'identity' => $result->getIdentity(),
134
            'message' => $messages[0] // first message is user readable, suitable for showing back to the login form
135
        );
136
    }
137
138
    /**
139
     * Return all nodes (organizational units, containers, and domains) within the current base DN.
140
     *
141
     * @param boolean $cached Cache the results from AD, so that subsequent calls are faster. Enabled by default.
142
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
143
     * @return array
144
     */
145
    public function getNodes($cached = true, $attributes = array())
146
    {
147
        $cache = self::get_cache();
148
        $results = $cache->load('nodes' . md5(implode('', $attributes)));
149
150
        if (!$results || !$cached) {
151
            $results = array();
152
            $records = $this->gateway->getNodes(null, Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes);
153
            foreach ($records as $record) {
154
                $results[$record['dn']] = $record;
155
            }
156
157
            $cache->save($results);
158
        }
159
160
        return $results;
161
    }
162
163
    /**
164
     * Return all AD groups in configured search locations, including all nested groups.
165
     * Uses groups_search_locations if defined, otherwise falls back to NULL, which tells LDAPGateway
166
     * to use the default baseDn defined in the connection.
167
     *
168
     * @param boolean $cached Cache the results from AD, so that subsequent calls are faster. Enabled by default.
169
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
170
     * @param string $indexBy Attribute to use as an index.
171
     * @return array
172
     */
173
    public function getGroups($cached = true, $attributes = array(), $indexBy = 'dn')
174
    {
175
        $searchLocations = $this->config()->groups_search_locations ?: array(null);
176
        $cache = self::get_cache();
177
        $results = $cache->load('groups' . md5(implode('', array_merge($searchLocations, $attributes))));
178
179
        if (!$results || !$cached) {
180
            $results = array();
181 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...
182
                $records = $this->gateway->getGroups($searchLocation, Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes);
183
                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...
184
                    continue;
185
                }
186
187
                foreach ($records as $record) {
188
                    $results[$record[$indexBy]] = $record;
189
                }
190
            }
191
192
            $cache->save($results);
193
        }
194
195
        return $results;
196
    }
197
198
    /**
199
     * Return all member groups (and members of those, recursively) underneath a specific group DN.
200
     * Note that these get cached in-memory per-request for performance to avoid re-querying for the same results.
201
     *
202
     * @param string $dn
203
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
204
     * @return array
205
     */
206
    public function getNestedGroups($dn, $attributes = array())
207
    {
208
        if (isset(self::$_cache_nested_groups[$dn])) {
209
            return self::$_cache_nested_groups[$dn];
210
        }
211
212
        $searchLocations = $this->config()->groups_search_locations ?: array(null);
213
        $results = array();
214 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...
215
            $records = $this->gateway->getNestedGroups($dn, $searchLocation, Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes);
216
            foreach ($records as $record) {
217
                $results[$record['dn']] = $record;
218
            }
219
        }
220
221
        self::$_cache_nested_groups[$dn] = $results;
222
        return $results;
223
    }
224
225
    /**
226
     * Get a particular AD group's data given a GUID.
227
     *
228
     * @param string $guid
229
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
230
     * @return array
231
     */
232 View Code Duplication
    public function getGroupByGUID($guid, $attributes = array())
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...
233
    {
234
        $searchLocations = $this->config()->groups_search_locations ?: array(null);
235
        foreach ($searchLocations as $searchLocation) {
236
            $records = $this->gateway->getGroupByGUID($guid, $searchLocation, Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes);
237
            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...
238
                return $records[0];
239
            }
240
        }
241
    }
242
243
    /**
244
     * Get a particular AD group's data given a DN.
245
     *
246
     * @param string $dn
247
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
248
     * @return array
249
     */
250 View Code Duplication
    public function getGroupByDN($dn, $attributes = array())
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...
251
    {
252
        $searchLocations = $this->config()->groups_search_locations ?: array(null);
253
        foreach ($searchLocations as $searchLocation) {
254
            $records = $this->gateway->getGroupByDN($dn, $searchLocation, Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes);
255
            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...
256
                return $records[0];
257
            }
258
        }
259
    }
260
261
    /**
262
     * Return all AD users in configured search locations, including all users in nested groups.
263
     * Uses users_search_locations if defined, otherwise falls back to NULL, which tells LDAPGateway
264
     * to use the default baseDn defined in the connection.
265
     *
266
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
267
     * @return array
268
     */
269
    public function getUsers($attributes = array())
270
    {
271
        $searchLocations = $this->config()->users_search_locations ?: array(null);
272
        $results = array();
273
274 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...
275
            $records = $this->gateway->getUsers($searchLocation, Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes);
276
            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...
277
                continue;
278
            }
279
280
            foreach ($records as $record) {
281
                $results[$record['objectguid']] = $record;
282
            }
283
        }
284
285
        return $results;
286
    }
287
288
    /**
289
     * Get a specific AD user's data given a GUID.
290
     *
291
     * @param string $guid
292
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
293
     * @return array
294
     */
295 View Code Duplication
    public function getUserByGUID($guid, $attributes = array())
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...
296
    {
297
        $searchLocations = $this->config()->users_search_locations ?: array(null);
298
        foreach ($searchLocations as $searchLocation) {
299
            $records = $this->gateway->getUserByGUID($guid, $searchLocation, Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes);
300
            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...
301
                return $records[0];
302
            }
303
        }
304
    }
305
306
    /**
307
     * Get a specific AD user's data given a DN.
308
     *
309
     * @param string $dn
310
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
311
     *
312
     * @return array
313
     */
314 View Code Duplication
    public function getUserByDN($dn, $attributes = array())
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...
315
    {
316
        $searchLocations = $this->config()->users_search_locations ?: array(null);
317
        foreach ($searchLocations as $searchLocation) {
318
            $records = $this->gateway->getUserByDN($dn, $searchLocation, Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes);
319
            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...
320
                return $records[0];
321
            }
322
        }
323
    }
324
325
    /**
326
     * Get a specific user's data given an email.
327
     *
328
     * @param string $email
329
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
330
     * @return array
331
     */
332 View Code Duplication
    public function getUserByEmail($email, $attributes = array())
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...
333
    {
334
        $searchLocations = $this->config()->users_search_locations ?: array(null);
335
        foreach ($searchLocations as $searchLocation) {
336
            $records = $this->gateway->getUserByEmail($email, $searchLocation, Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes);
337
            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...
338
                return $records[0];
339
            }
340
        }
341
    }
342
343
    /**
344
     * Get a specific user's data given a username.
345
     *
346
     * @param string $username
347
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
348
     * @return array
349
     */
350 View Code Duplication
    public function getUserByUsername($username, $attributes = array())
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...
351
    {
352
        $searchLocations = $this->config()->users_search_locations ?: array(null);
353
        foreach ($searchLocations as $searchLocation) {
354
            $records = $this->gateway->getUserByUsername($username, $searchLocation, Zend\Ldap\Ldap::SEARCH_SCOPE_SUB, $attributes);
355
            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...
356
                return $records[0];
357
            }
358
        }
359
    }
360
361
    /**
362
     * Get a username for an email.
363
     *
364
     * @param string $email
365
     * @return string|null
366
     */
367
    public function getUsernameByEmail($email)
368
    {
369
        $data = $this->getUserByEmail($email);
370
        if (empty($data)) {
371
            return null;
372
        }
373
374
        return $this->gateway->getCanonicalUsername($data);
375
    }
376
377
    /**
378
     * Given a group DN, get the group membership data in LDAP.
379
     *
380
     * @param string $dn
381
     * @return array
382
     */
383
    public function getLDAPGroupMembers($dn)
384
    {
385
        $groupObj = Group::get()->filter('DN', $dn)->first();
386
        $groupData = $this->getGroupByGUID($groupObj->GUID);
387
        $members = !empty($groupData['member']) ? $groupData['member'] : array();
388
        // If a user belongs to a single group, this comes through as a string.
389
        // Normalise to a array so it's consistent.
390
        if ($members && is_string($members)) {
391
            $members = array($members);
392
        }
393
394
        return $members;
395
    }
396
397
    /**
398
     * Update the current Member record with data from LDAP.
399
     *
400
     * Constraints:
401
     * - Member *must* be in the database before calling this as it will need the ID to be mapped to a {@link Group}.
402
     * - GUID of the member must have already been set, for integrity reasons we don't allow it to change here.
403
     *
404
     * @param Member
405
     * @param array|null $data If passed, this is pre-existing AD attribute data to update the Member with.
406
     *            If not given, the data will be looked up by the user's GUID.
407
     * @return bool
408
     */
409
    public function updateMemberFromLDAP(Member $member, $data = null)
410
    {
411
        if (!$this->enabled()) {
412
            return false;
413
        }
414
415
        if (!$member->GUID) {
416
            SS_Log::log(sprintf('Cannot update Member ID %s, GUID not set', $member->ID), SS_Log::WARN);
417
            return false;
418
        }
419
420
        if (!$data) {
421
            $data = $this->getUserByGUID($member->GUID);
422
            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...
423
                SS_Log::log(sprintf('Could not retrieve data for user. GUID: %s', $member->GUID), SS_Log::WARN);
424
                return false;
425
            }
426
        }
427
428
        $member->IsExpired = ($data['useraccountcontrol'] & 2) == 2;
429
        $member->LastSynced = (string)SS_Datetime::now();
430
431
        foreach ($member->config()->ldap_field_mappings as $attribute => $field) {
432
            if (!isset($data[$attribute])) {
433
                SS_Log::log(sprintf(
434
                    'Attribute %s configured in Member.ldap_field_mappings, but no available attribute in AD data (GUID: %s, Member ID: %s)',
435
                    $attribute,
436
                    $data['objectguid'],
437
                    $member->ID
438
                ), SS_Log::NOTICE);
439
440
                continue;
441
            }
442
443
            if ($attribute == 'thumbnailphoto') {
444
                $imageClass = $member->getRelationClass($field);
445
                if ($imageClass !== 'Image' && !is_subclass_of($imageClass, 'Image')) {
446
                    SS_Log::log(sprintf(
447
                        'Member field %s configured for thumbnailphoto AD attribute, but it isn\'t a valid relation to an Image class',
448
                        $field
449
                    ), SS_Log::WARN);
450
451
                    continue;
452
                }
453
454
                $filename = sprintf('thumbnailphoto-%s.jpg', $data['samaccountname']);
455
                $path = ASSETS_DIR . '/' . $member->config()->ldap_thumbnail_path;
456
                $absPath = BASE_PATH . '/' . $path;
457
                if (!file_exists($absPath)) {
458
                    Filesystem::makeFolder($absPath);
459
                }
460
461
                // remove existing record if it exists
462
                $existingObj = $member->getComponent($field);
463
                if ($existingObj && $existingObj->exists()) {
464
                    $existingObj->delete();
465
                }
466
467
                // The image data is provided in raw binary.
468
                file_put_contents($absPath . '/' . $filename, $data[$attribute]);
469
                $record = new $imageClass();
470
                $record->Name = $filename;
471
                $record->Filename = $path . '/' . $filename;
472
                $record->write();
473
474
                $relationField = $field . 'ID';
475
                $member->{$relationField} = $record->ID;
476
            } else {
477
                $member->$field = $data[$attribute];
478
            }
479
        }
480
481
        // if a default group was configured, ensure the user is in that group
482
        if ($this->config()->default_group) {
483
            $group = Group::get()->filter('Code', $this->config()->default_group)->limit(1)->first();
484
            if (!($group && $group->exists())) {
485
                SS_Log::log(
486
                    sprintf(
487
                        'LDAPService.default_group misconfiguration! There is no such group with Code = \'%s\'',
488
                        $this->config()->default_group
489
                    ),
490
                    SS_Log::WARN
491
                );
492
            } else {
493
                $group->Members()->add($member, array(
494
                    'IsImportedFromLDAP' => '1'
495
                ));
496
            }
497
        }
498
499
        // this is to keep track of which groups the user gets mapped to
500
        // and we'll use that later to remove them from any groups that they're no longer mapped to
501
        $mappedGroupIDs = array();
502
503
        // ensure the user is in any mapped groups
504
        if (isset($data['memberof'])) {
505
            $ldapGroups = is_array($data['memberof']) ? $data['memberof'] : array($data['memberof']);
506
            foreach ($ldapGroups as $groupDN) {
507
                foreach (LDAPGroupMapping::get() as $mapping) {
508
                    if (!$mapping->DN) {
509
                        SS_Log::log(
510
                            sprintf(
511
                                'LDAPGroupMapping ID %s is missing DN field. Skipping',
512
                                $mapping->ID
513
                            ),
514
                            SS_Log::WARN
515
                        );
516
                        continue;
517
                    }
518
519
                    // the user is a direct member of group with a mapping, add them to the SS group.
520 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...
521
                        $group = $mapping->Group();
522
                        if ($group && $group->exists()) {
523
                            $group->Members()->add($member, array(
524
                                'IsImportedFromLDAP' => '1'
525
                            ));
526
                            $mappedGroupIDs[] = $mapping->GroupID;
527
                        }
528
                    }
529
530
                    // the user *might* be a member of a nested group provided the scope of the mapping
531
                    // is to include the entire subtree. Check all those mappings and find the LDAP child groups
532
                    // to see if they are a member of one of those. If they are, add them to the SS group
533
                    if ($mapping->Scope == 'Subtree') {
534
                        $childGroups = $this->getNestedGroups($mapping->DN, array('dn'));
535
                        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...
536
                            continue;
537
                        }
538
539
                        foreach ($childGroups as $childGroupDN => $childGroupRecord) {
540 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...
541
                                $group = $mapping->Group();
542
                                if ($group && $group->exists()) {
543
                                    $group->Members()->add($member, array(
544
                                        'IsImportedFromLDAP' => '1'
545
                                    ));
546
                                    $mappedGroupIDs[] = $mapping->GroupID;
547
                                }
548
                            }
549
                        }
550
                    }
551
                }
552
            }
553
        }
554
555
        // remove the user from any previously mapped groups, where the mapping has since been removed
556
        $groupRecords = DB::query(sprintf('SELECT "GroupID" FROM "Group_Members" WHERE "IsImportedFromLDAP" = 1 AND "MemberID" = %s', $member->ID));
557
        foreach ($groupRecords as $groupRecord) {
558
            if (!in_array($groupRecord['GroupID'], $mappedGroupIDs)) {
559
                $group = Group::get()->byId($groupRecord['GroupID']);
560
                // Some groups may no longer exist. SilverStripe does not clean up join tables.
561
                if ($group) {
562
                    $group->Members()->remove($member);
563
                }
564
            }
565
        }
566
        // This will throw an exception if there are two distinct GUIDs with the same email address.
567
        // We are happy with a raw 500 here at this stage.
568
        $member->write();
569
    }
570
571
    /**
572
     * Sync a specific Group by updating it with LDAP data.
573
     *
574
     * @param Group $group An existing Group or a new Group object
575
     * @param array $data LDAP group object data
576
     *
577
     * @return bool
578
     */
579
    public function updateGroupFromLDAP(Group $group, $data)
580
    {
581
        if (!$this->enabled()) {
582
            return false;
583
        }
584
585
        // Synchronise specific guaranteed fields.
586
        $group->Code = $data['samaccountname'];
587
        if (!empty($data['name'])) {
588
            $group->Title = $data['name'];
589
        } else {
590
            $group->Title = $data['samaccountname'];
591
        }
592
        if (!empty($data['description'])) {
593
            $group->Description = $data['description'];
594
        }
595
        $group->DN = $data['dn'];
596
        $group->LastSynced = (string)SS_Datetime::now();
597
        $group->write();
598
599
        // Mappings on this group are automatically maintained to contain just the group's DN.
600
        // First, scan through existing mappings and remove ones that are not matching (in case the group moved).
601
        $hasCorrectMapping = false;
602
        foreach ($group->LDAPGroupMappings() as $mapping) {
603
            if ($mapping->DN === $data['dn']) {
604
                // This is the correct mapping we want to retain.
605
                $hasCorrectMapping = true;
606
            } else {
607
                $mapping->delete();
608
            }
609
        }
610
611
        // Second, if the main mapping was not found, add it in.
612
        if (!$hasCorrectMapping) {
613
            $mapping = new LDAPGroupMapping();
614
            $mapping->DN = $data['dn'];
0 ignored issues
show
Documentation introduced by
The property DN does not exist on object<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...
615
            $mapping->write();
616
            $group->LDAPGroupMappings()->add($mapping);
617
        }
618
    }
619
620
    /**
621
     * Creates a new LDAP user from the passed Member record.
622
     * Note that the Member record must have a non-empty Username field for this to work.
623
     *
624
     * @param Member $member
625
     */
626
    public function createLDAPUser(Member $member)
627
    {
628
        if (!$this->enabled()) {
629
            return;
630
        }
631
        if (empty($member->Username)) {
632
            throw new ValidationException('Member missing Username. Cannot create LDAP user');
633
        }
634
        if (!$this->config()->new_users_dn) {
635
            throw new Exception('LDAPService::new_users_dn must be configured to create LDAP users');
636
        }
637
638
        // Normalise username to lowercase to ensure we don't have duplicates of different cases
639
        $member->Username = strtolower($member->Username);
640
641
        // Create user in LDAP using available information.
642
        $dn = sprintf('CN=%s,%s', $member->Username, $this->config()->new_users_dn);
643
644
        try {
645
            $this->add($dn, array(
646
                'objectclass' => 'user',
647
                'cn' => $member->Username,
648
                'accountexpires' => '9223372036854775807',
649
                'useraccountcontrol' => '66048',
650
                'userprincipalname' => sprintf(
651
                    '%s@%s',
652
                    $member->Username,
653
                    $this->gateway->config()->options['accountDomainName']
654
                ),
655
            ));
656
        } catch (\Exception $e) {
657
            throw new ValidationException('LDAP synchronisation failure: '.$e->getMessage());
658
        }
659
660
        $user = $this->getUserByUsername($member->Username);
661
        if (empty($user['objectguid'])) {
662
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
663
        }
664
665
        // Creation was successful, mark the user as LDAP managed by setting the GUID.
666
        $member->GUID = $user['objectguid'];
667
    }
668
669
    /**
670
     * Update the Member data back to the corresponding LDAP user object.
671
     *
672
     * @param Member $member
673
     * @throws ValidationException
674
     */
675
    public function updateLDAPFromMember(Member $member)
676
    {
677
        if (!$this->enabled()) {
678
            return;
679
        }
680
        if (!$member->GUID) {
681
            throw new ValidationException('Member missing GUID. Cannot update LDAP user');
682
        }
683
684
        $data = $this->getUserByGUID($member->GUID);
685
        if (empty($data['objectguid'])) {
686
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
687
        }
688
689
        if (empty($member->Username)) {
690
            throw new ValidationException('Member missing Username. Cannot update LDAP user');
691
        }
692
693
        $dn = $data['distinguishedname'];
694
695
        // Normalise username to lowercase to ensure we don't have duplicates of different cases
696
        $member->Username = strtolower($member->Username);
697
698
        try {
699
            // If the common name (cn) has changed, we need to ensure they've been moved
700
            // to the new DN, to avoid any clashes between user objects.
701
            if ($data['cn'] != $member->Username) {
702
                $newDn = sprintf('CN=%s,%s', $member->Username, preg_replace('/^CN=(.+?),/', '', $dn));
703
                $this->move($dn, $newDn);
704
                $dn = $newDn;
705
            }
706
        } catch (\Exception $e) {
707
            throw new ValidationException('LDAP move failure: '.$e->getMessage());
708
        }
709
710
        try {
711
            $attributes = array(
712
                'displayname' => sprintf('%s %s', $member->FirstName, $member->Surname),
713
                'name' => sprintf('%s %s', $member->FirstName, $member->Surname),
714
                'userprincipalname' => sprintf(
715
                    '%s@%s',
716
                    $member->Username,
717
                    $this->gateway->config()->options['accountDomainName']
718
                ),
719
            );
720
            foreach ($member->config()->ldap_field_mappings as $attribute => $field) {
721
                $relationClass = $member->getRelationClass($field);
722
                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...
723
                    // todo no support for writing back relations yet.
724
                } else {
725
                    $attributes[$attribute] = $member->$field;
726
                }
727
            }
728
729
            $this->update($dn, $attributes);
730
        } catch (\Exception $e) {
731
            throw new ValidationException('LDAP synchronisation failure: '.$e->getMessage());
732
        }
733
    }
734
735
    /**
736
     * Ensure the user belongs to the correct groups in LDAP from their membership
737
     * to local LDAP mapped SilverStripe groups.
738
     *
739
     * This also removes them from LDAP groups if they've been taken out of one.
740
     * It will not affect group membership of non-mapped groups, so it will
741
     * not touch such internal AD groups like "Domain Users".
742
     *
743
     * @param Member $member
744
     */
745
    public function updateLDAPGroupsForMember(Member $member)
746
    {
747
        if (!$this->enabled()) {
748
            return;
749
        }
750
        if (!$member->GUID) {
751
            throw new ValidationException('Member missing GUID. Cannot update LDAP user');
752
        }
753
754
        $addGroups = array();
755
        $removeGroups = array();
756
757
        $user = $this->getUserByGUID($member->GUID);
758
        if (empty($user['objectguid'])) {
759
            throw new ValidationException('LDAP update failure: user missing GUID');
760
        }
761
762
        // If a user belongs to a single group, this comes through as a string.
763
        // Normalise to a array so it's consistent.
764
        $existingGroups = !empty($user['memberof']) ? $user['memberof'] : array();
765
        if ($existingGroups && is_string($existingGroups)) {
766
            $existingGroups = array($existingGroups);
767
        }
768
769
        foreach ($member->Groups() as $group) {
770
            if (!$group->GUID) {
771
                continue;
772
            }
773
774
            // mark this group as something we need to ensure the user belongs to in LDAP.
775
            $addGroups[] = $group->DN;
776
        }
777
778
        // Which existing LDAP groups are not in the add groups? We'll check these groups to
779
        // see if the user should be removed from any of them.
780
        $remainingGroups = array_diff($existingGroups, $addGroups);
781
782
        foreach ($remainingGroups as $groupDn) {
783
            // We only want to be removing groups we have a local Group mapped to. Removing
784
            // membership for anything else would be bad!
785
            $group = Group::get()->filter('DN', $groupDn)->first();
786
            if (!$group || !$group->exists()) {
787
                continue;
788
            }
789
790
            // this group should be removed from the user's memberof attribute, as it's been removed.
791
            $removeGroups[] = $groupDn;
792
        }
793
794
        // go through the groups we want the user to be in and ensure they're in them.
795
        foreach ($addGroups as $groupDn) {
796
            $members = $this->getLDAPGroupMembers($groupDn);
797
798
            // this user is already in the group, no need to do anything.
799
            if (in_array($user['distinguishedname'], $members)) {
800
                continue;
801
            }
802
803
            $members[] = $user['distinguishedname'];
804
805
            try {
806
                $this->update($groupDn, array('member' => $members));
807
            } catch (\Exception $e) {
808
                throw new ValidationException('LDAP group membership add failure: '.$e->getMessage());
809
            }
810
        }
811
812
        // go through the groups we _don't_ want the user to be in and ensure they're taken out of them.
813
        foreach ($removeGroups as $groupDn) {
814
            $members = $this->getLDAPGroupMembers($groupDn);
815
816
            // remove the user from the members data.
817
            if (in_array($user['distinguishedname'], $members)) {
818
                foreach ($members as $i => $dn) {
819
                    if ($dn == $user['distinguishedname']) {
820
                        unset($members[$i]);
821
                    }
822
                }
823
            }
824
825
            try {
826
                $this->update($groupDn, array('member' => $members));
827
            } catch (\Exception $e) {
828
                throw new ValidationException('LDAP group membership remove failure: '.$e->getMessage());
829
            }
830
        }
831
    }
832
833
    /**
834
     * Change a members password on the AD. Works with ActiveDirectory compatible services that saves the
835
     * password in the `unicodePwd` attribute.
836
     *
837
     * @todo Use the Zend\Ldap\Attribute::setPassword functionality to create a password in
838
     * an abstract way, so it works on other LDAP directories, not just Active Directory.
839
     *
840
     * Ensure that the LDAP bind:ed user can change passwords and that the connection is secure.
841
     *
842
     * @param Member $member
843
     * @param string $password
844
     * @return ValidationResult
845
     * @throws Exception
846
     */
847
    public function setPassword(Member $member, $password)
848
    {
849
        $validationResult = ValidationResult::create(true);
850
        if (!$member->GUID) {
851
            SS_Log::log(sprintf('Cannot update Member ID %s, GUID not set', $member->ID), SS_Log::WARN);
852
            $validationResult->error(_t('LDAPAuthenticator.NOUSER', 'Your account hasn\'t been setup properly, please contact an administrator.'));
853
            return $validationResult;
854
        }
855
856
        $userData = $this->getUserByGUID($member->GUID);
857
        if (empty($userData['distinguishedname'])) {
858
            $validationResult->error(_t('LDAPAuthenticator.NOUSER', 'Your account hasn\'t been setup properly, please contact an administrator.'));
859
            return $validationResult;
860
        }
861
862
        try {
863
            $this->update(
864
                $userData['distinguishedname'],
865
                array('unicodePwd' => iconv('UTF-8', 'UTF-16LE', sprintf('"%s"', $password)))
866
            );
867
        } catch (Exception $e) {
868
            // Try to parse the exception to get the error message to display to user, eg:
869
            // Can't change password for Member.ID "13": 0x13 (Constraint violation; 0000052D: Constraint violation - check_password_restrictions: the password does not meet the complexity criteria!): updating: CN=User Name,OU=Users,DC=foo,DC=company,DC=com
870
            $pattern = '/^([^\s])*\s([^\;]*);\s([^\:]*):\s([^\:]*):\s([^\)]*)/i';
871
            if (preg_match($pattern, $e->getMessage(), $matches) && !empty($matches[5])) {
872
                $validationResult->error($matches[5]);
873
            } else {
874
                // Unparsable exception, an administrator should check the logs
875
                $validationResult->error(_t('LDAPAuthenticator.CANTCHANGEPASSWORD', 'We couldn\'t change your password, please contact an administrator.'));
876
            }
877
        }
878
879
        return $validationResult;
880
    }
881
882
    /**
883
     * Delete an LDAP user mapped to the Member record
884
     * @param Member $member
885
     */
886
    public function deleteLDAPMember(Member $member) {
887
        if (!$this->enabled()) {
888
            return;
889
        }
890
        if (!$member->GUID) {
891
            throw new ValidationException('Member missing GUID. Cannot delete LDAP user');
892
        }
893
        $data = $this->getUserByGUID($member->GUID);
894
        if (empty($data['distinguishedname'])) {
895
            throw new ValidationException('LDAP delete failure: could not find distinguishedname attribute');
896
        }
897
898
        try {
899
            $this->delete($data['distinguishedname']);
900
        } catch (\Exception $e) {
901
            throw new ValidationException('LDAP delete user failed: '.$e->getMessage());
902
        }
903
    }
904
905
    /**
906
     * A simple proxy to LDAP update operation.
907
     *
908
     * @param string $dn Location to add the entry at.
909
     * @param array $attributes A simple associative array of attributes.
910
     */
911
    public function update($dn, array $attributes)
912
    {
913
        $this->gateway->update($dn, $attributes);
914
    }
915
916
    /**
917
     * A simple proxy to LDAP delete operation.
918
     *
919
     * @param string $dn Location of object to delete
920
     * @param bool $recursively Recursively delete nested objects?
921
     */
922
    public function delete($dn, $recursively = false)
923
    {
924
        $this->gateway->delete($dn, $recursively);
925
    }
926
927
    /**
928
     * A simple proxy to LDAP copy/delete operation.
929
     *
930
     * @param string $fromDn
931
     * @param string $toDn
932
     * @param bool $recursively Recursively move nested objects?
933
     */
934
    public function move($fromDn, $toDn, $recursively = false)
935
    {
936
        $this->gateway->move($fromDn, $toDn, $recursively);
937
    }
938
939
    /**
940
     * A simple proxy to LDAP add operation.
941
     *
942
     * @param string $dn Location to add the entry at.
943
     * @param array $attributes A simple associative array of attributes.
944
     */
945
    public function add($dn, array $attributes)
946
    {
947
        $this->gateway->add($dn, $attributes);
948
    }
949
}
950