Completed
Pull Request — master (#18)
by Matt
04:59
created

LDAPService::getGateway()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\LDAP\Services;
4
5
use Exception;
6
use function file_put_contents;
7
use function filesize;
8
use finfo;
9
use Psr\Log\LoggerInterface;
10
use Psr\SimpleCache\CacheInterface;
11
use SilverStripe\Assets\File;
12
use SilverStripe\Assets\Folder;
13
use SilverStripe\Assets\Upload;
14
use SilverStripe\Assets\Upload_Validator;
15
use SilverStripe\LDAP\Model\LDAPGateway;
16
use SilverStripe\LDAP\Model\LDAPGroupMapping;
17
use SilverStripe\Assets\Image;
18
use SilverStripe\Assets\Filesystem;
19
use SilverStripe\Core\Config\Config;
20
use SilverStripe\Core\Config\Configurable;
21
use SilverStripe\Core\Flushable;
22
use SilverStripe\Core\Extensible;
23
use SilverStripe\Core\Injector\Injector;
24
use SilverStripe\Core\Injector\Injectable;
25
use SilverStripe\ORM\DB;
26
use SilverStripe\ORM\FieldType\DBDatetime;
27
use SilverStripe\ORM\ValidationException;
28
use SilverStripe\ORM\ValidationResult;
29
use SilverStripe\Security\Group;
30
use SilverStripe\Security\Member;
31
use SilverStripe\Security\RandomGenerator;
32
use function sys_get_temp_dir;
33
use function tempnam;
34
use Zend\Ldap\Ldap;
35
36
/**
37
 * Class LDAPService
38
 *
39
 * Provides LDAP operations expressed in terms of the SilverStripe domain.
40
 * All other modules should access LDAP through this class.
41
 *
42
 * This class builds on top of LDAPGateway's detailed code by adding:
43
 * - caching
44
 * - data aggregation and restructuring from multiple lower-level calls
45
 * - error handling
46
 *
47
 * LDAPService relies on Zend LDAP module's data structures for some parameters and some return values.
48
 */
49
class LDAPService implements Flushable
50
{
51
    use Injectable;
52
    use Extensible;
53
    use Configurable;
54
55
    /**
56
     * @var array
57
     */
58
    private static $dependencies = [
59
        'gateway' => '%$' . LDAPGateway::class
60
    ];
61
62
    /**
63
     * If configured, only user objects within these locations will be exposed to this service.
64
     *
65
     * @var array
66
     * @config
67
     */
68
    private static $users_search_locations = [];
69
70
    /**
71
     * If configured, only group objects within these locations will be exposed to this service.
72
     * @var array
73
     *
74
     * @config
75
     */
76
    private static $groups_search_locations = [];
77
78
    /**
79
     * Location to create new users in (distinguished name).
80
     * @var string
81
     *
82
     * @config
83
     */
84
    private static $new_users_dn;
85
86
    /**
87
     * Location to create new groups in (distinguished name).
88
     * @var string
89
     *
90
     * @config
91
     */
92
    private static $new_groups_dn;
93
94
    /**
95
     * @var array
96
     */
97
    private static $_cache_nested_groups = [];
98
99
    /**
100
     * If this is configured to a "Code" value of a {@link Group} in SilverStripe, the user will always
101
     * be added to this group's membership when imported, regardless of any sort of group mappings.
102
     *
103
     * @var string
104
     * @config
105
     */
106
    private static $default_group;
107
108
    /**
109
     * For samba4 directory, there is no way to enforce password history on password resets.
110
     * This only happens with changePassword (which requires the old password).
111
     * This works around it by making the old password up and setting it administratively.
112
     *
113
     * A cleaner fix would be to use the LDAP_SERVER_POLICY_HINTS_OID connection flag,
114
     * but it's not implemented in samba https://bugzilla.samba.org/show_bug.cgi?id=12020
115
     *
116
     * @var bool
117
     */
118
    private static $password_history_workaround = false;
119
120
    /**
121
     * Get the cache object used for LDAP results. Note that the default lifetime set here
122
     * is 8 hours, but you can change that by adding configuration:
123
     *
124
     * <code>
125
     * SilverStripe\Core\Injector\Injector:
126
     *   Psr\SimpleCache\CacheInterface.ldap:
127
     *     constructor:
128
     *       defaultLifetime: 3600 # time in seconds
129
     * </code>
130
     *
131
     * @return Psr\SimpleCache\CacheInterface
0 ignored issues
show
Bug introduced by
The type SilverStripe\LDAP\Servic...pleCache\CacheInterface was not found. Did you mean Psr\SimpleCache\CacheInterface? If so, make sure to prefix the type with \.
Loading history...
132
     */
133
    public static function get_cache()
134
    {
135
        return Injector::inst()->get(CacheInterface::class . '.ldap');
136
    }
137
138
    /**
139
     * Flushes out the LDAP results cache when flush=1 is called.
140
     */
141
    public static function flush()
142
    {
143
        /** @var CacheInterface $cache */
144
        $cache = self::get_cache();
145
        $cache->clear();
146
    }
147
148
    /**
149
     * @var LDAPGateway
150
     */
151
    public $gateway;
152
153
    public function __construct()
154
    {
155
        $this->constructExtensions();
0 ignored issues
show
Deprecated Code introduced by
The function SilverStripe\LDAP\Servic...::constructExtensions() has been deprecated: 4.0..5.0 Extensions and methods are now lazy-loaded ( Ignorable by Annotation )

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

155
        /** @scrutinizer ignore-deprecated */ $this->constructExtensions();

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
156
    }
157
158
    /**
159
     * Setter for gateway. Useful for overriding the gateway with a fake for testing.
160
     * @var LDAPGateway
161
     */
162
    public function setGateway($gateway)
163
    {
164
        $this->gateway = $gateway;
165
    }
166
167
    /**
168
     * Return the LDAP gateway currently in use. Can be strung together to access the underlying Zend\Ldap instance, or
169
     * the PHP ldap resource itself. For example:
170
     * - Get the Zend\Ldap object: $service->getGateway()->getLdap() // Zend\Ldap\Ldap object
171
     * - Get the underlying PHP ldap resource: $service->getGateway()->getLdap()->getResource() // php resource
172
     *
173
     * @return LDAPGateway
174
     */
175
    public function getGateway()
176
    {
177
        return $this->gateway;
178
    }
179
180
    /**
181
     * Checkes whether or not the service is enabled.
182
     *
183
     * @return bool
184
     */
185
    public function enabled()
186
    {
187
        $options = Config::inst()->get(LDAPGateway::class, 'options');
188
        return !empty($options);
189
    }
190
191
    /**
192
     * Authenticate the given username and password with LDAP.
193
     *
194
     * @param string $username
195
     * @param string $password
196
     *
197
     * @return array
198
     */
199
    public function authenticate($username, $password)
200
    {
201
        $result = $this->gateway->authenticate($username, $password);
202
        $messages = $result->getMessages();
203
204
        // all messages beyond the first one are for debugging and
205
        // not suitable to display to the user.
206
        foreach ($messages as $i => $message) {
207
            if ($i > 0) {
208
                $this->getLogger()->debug(str_replace("\n", "\n  ", $message));
209
            }
210
        }
211
212
        $message = $messages[0]; // first message is user readable, suitable for showing on login form
213
214
        // show better errors than the defaults for various status codes returned by LDAP
215
        if (!empty($messages[1]) && strpos($messages[1], 'NT_STATUS_ACCOUNT_LOCKED_OUT') !== false) {
216
            $message = _t(
217
                __CLASS__ . '.ACCOUNTLOCKEDOUT',
218
                'Your account has been temporarily locked because of too many failed login attempts. ' .
219
                'Please try again later.'
220
            );
221
        }
222
        if (!empty($messages[1]) && strpos($messages[1], 'NT_STATUS_LOGON_FAILURE') !== false) {
223
            $message = _t(
224
                __CLASS__ . '.INVALIDCREDENTIALS',
225
                'The provided details don\'t seem to be correct. Please try again.'
226
            );
227
        }
228
229
        return [
230
            'success' => $result->getCode() === 1,
231
            'identity' => $result->getIdentity(),
232
            'message' => $message,
233
            'code' => $result->getCode()
234
        ];
235
    }
236
237
    /**
238
     * Return all nodes (organizational units, containers, and domains) within the current base DN.
239
     *
240
     * @param boolean $cached Cache the results from AD, so that subsequent calls are faster. Enabled by default.
241
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
242
     * @return array
243
     */
244
    public function getNodes($cached = true, $attributes = [])
245
    {
246
        $cache = self::get_cache();
247
        $cacheKey = 'nodes' . md5(implode('', $attributes));
248
        $results = $cache->has($cacheKey);
249
250
        if (!$results || !$cached) {
251
            $results = [];
252
            $records = $this->gateway->getNodes(null, Ldap::SEARCH_SCOPE_SUB, $attributes);
253
            foreach ($records as $record) {
254
                $results[$record['dn']] = $record;
255
            }
256
257
            $cache->set($cacheKey, $results);
258
        }
259
260
        return $results;
261
    }
262
263
    /**
264
     * Return all AD groups in configured search locations, including all nested groups.
265
     * Uses groups_search_locations if defined, otherwise falls back to NULL, which tells LDAPGateway
266
     * to use the default baseDn defined in the connection.
267
     *
268
     * @param boolean $cached Cache the results from AD, so that subsequent calls are faster. Enabled by default.
269
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
270
     * @param string $indexBy Attribute to use as an index.
271
     * @return array
272
     */
273
    public function getGroups($cached = true, $attributes = [], $indexBy = 'dn')
274
    {
275
        $searchLocations = $this->config()->groups_search_locations ?: [null];
276
        $cache = self::get_cache();
277
        $cacheKey = 'groups' . md5(implode('', array_merge($searchLocations, $attributes)));
278
        $results = $cache->has($cacheKey);
279
280
        if (!$results || !$cached) {
281
            $results = [];
282
            foreach ($searchLocations as $searchLocation) {
283
                $records = $this->gateway->getGroups($searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
284
                if (!$records) {
285
                    continue;
286
                }
287
288
                foreach ($records as $record) {
289
                    $results[$record[$indexBy]] = $record;
290
                }
291
            }
292
293
            $cache->set($cacheKey, $results);
294
        }
295
296
        if ($cached && $results === true) {
297
            $results = $cache->get($cacheKey);
298
        }
299
300
        return $results;
301
    }
302
303
    /**
304
     * Return all member groups (and members of those, recursively) underneath a specific group DN.
305
     * Note that these get cached in-memory per-request for performance to avoid re-querying for the same results.
306
     *
307
     * @param string $dn
308
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
309
     * @return array
310
     */
311
    public function getNestedGroups($dn, $attributes = [])
312
    {
313
        if (isset(self::$_cache_nested_groups[$dn])) {
314
            return self::$_cache_nested_groups[$dn];
315
        }
316
317
        $searchLocations = $this->config()->groups_search_locations ?: [null];
318
        $results = [];
319
        foreach ($searchLocations as $searchLocation) {
320
            $records = $this->gateway->getNestedGroups($dn, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
321
            foreach ($records as $record) {
322
                $results[$record['dn']] = $record;
323
            }
324
        }
325
326
        self::$_cache_nested_groups[$dn] = $results;
327
        return $results;
328
    }
329
330
    /**
331
     * Get a particular AD group's data given a GUID.
332
     *
333
     * @param string $guid
334
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
335
     * @return array
336
     */
337
    public function getGroupByGUID($guid, $attributes = [])
338
    {
339
        $searchLocations = $this->config()->groups_search_locations ?: [null];
340
        foreach ($searchLocations as $searchLocation) {
341
            $records = $this->gateway->getGroupByGUID($guid, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
342
            if ($records) {
343
                return $records[0];
344
            }
345
        }
346
    }
347
348
    /**
349
     * Get a particular AD group's data given a DN.
350
     *
351
     * @param string $dn
352
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
353
     * @return array
354
     */
355
    public function getGroupByDN($dn, $attributes = [])
356
    {
357
        $searchLocations = $this->config()->groups_search_locations ?: [null];
358
        foreach ($searchLocations as $searchLocation) {
359
            $records = $this->gateway->getGroupByDN($dn, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
360
            if ($records) {
361
                return $records[0];
362
            }
363
        }
364
    }
365
366
    /**
367
     * Return all AD users in configured search locations, including all users in nested groups.
368
     * Uses users_search_locations if defined, otherwise falls back to NULL, which tells LDAPGateway
369
     * to use the default baseDn defined in the connection.
370
     *
371
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
372
     * @return array
373
     */
374
    public function getUsers($attributes = [])
375
    {
376
        $searchLocations = $this->config()->users_search_locations ?: [null];
377
        $results = [];
378
379
        foreach ($searchLocations as $searchLocation) {
380
            $records = $this->gateway->getUsersWithIterator($searchLocation, $attributes);
381
            if (!$records) {
382
                continue;
383
            }
384
385
            foreach ($records as $record) {
386
                $results[$record['objectguid']] = $record;
387
            }
388
        }
389
390
        return $results;
391
    }
392
393
    /**
394
     * Get a specific AD user's data given a GUID.
395
     *
396
     * @param string $guid
397
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
398
     * @return array
399
     */
400
    public function getUserByGUID($guid, $attributes = [])
401
    {
402
        $searchLocations = $this->config()->users_search_locations ?: [null];
403
        foreach ($searchLocations as $searchLocation) {
404
            $records = $this->gateway->getUserByGUID($guid, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
405
            if ($records) {
406
                return $records[0];
407
            }
408
        }
409
    }
410
411
    /**
412
     * Get a specific AD user's data given a DN.
413
     *
414
     * @param string $dn
415
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
416
     *
417
     * @return array
418
     */
419
    public function getUserByDN($dn, $attributes = [])
420
    {
421
        $searchLocations = $this->config()->users_search_locations ?: [null];
422
        foreach ($searchLocations as $searchLocation) {
423
            $records = $this->gateway->getUserByDN($dn, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
424
            if ($records) {
425
                return $records[0];
426
            }
427
        }
428
    }
429
430
    /**
431
     * Get a specific user's data given an email.
432
     *
433
     * @param string $email
434
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
435
     * @return array
436
     */
437
    public function getUserByEmail($email, $attributes = [])
438
    {
439
        $searchLocations = $this->config()->users_search_locations ?: [null];
440
        foreach ($searchLocations as $searchLocation) {
441
            $records = $this->gateway->getUserByEmail($email, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
442
            if ($records) {
443
                return $records[0];
444
            }
445
        }
446
    }
447
448
    /**
449
     * Get a specific user's data given a username.
450
     *
451
     * @param string $username
452
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
453
     * @return array
454
     */
455
    public function getUserByUsername($username, $attributes = [])
456
    {
457
        $searchLocations = $this->config()->users_search_locations ?: [null];
458
        foreach ($searchLocations as $searchLocation) {
459
            $records = $this->gateway->getUserByUsername(
460
                $username,
461
                $searchLocation,
462
                Ldap::SEARCH_SCOPE_SUB,
463
                $attributes
464
            );
465
            if ($records) {
466
                return $records[0];
467
            }
468
        }
469
    }
470
471
    /**
472
     * Get a username for an email.
473
     *
474
     * @param string $email
475
     * @return string|null
476
     */
477
    public function getUsernameByEmail($email)
478
    {
479
        $data = $this->getUserByEmail($email);
480
        if (empty($data)) {
481
            return null;
482
        }
483
484
        return $this->gateway->getCanonicalUsername($data);
485
    }
486
487
    /**
488
     * Given a group DN, get the group membership data in LDAP.
489
     *
490
     * @param string $dn
491
     * @return array
492
     */
493
    public function getLDAPGroupMembers($dn)
494
    {
495
        $groupObj = Group::get()->filter('DN', $dn)->first();
496
        $groupData = $this->getGroupByGUID($groupObj->GUID);
497
        $members = !empty($groupData['member']) ? $groupData['member'] : [];
498
        // If a user belongs to a single group, this comes through as a string.
499
        // Normalise to a array so it's consistent.
500
        if ($members && is_string($members)) {
501
            $members = [$members];
502
        }
503
504
        return $members;
505
    }
506
507
    /**
508
     * Update the current Member record with data from LDAP.
509
     *
510
     * It's allowed to pass an unwritten Member record here, because it's not always possible to satisfy
511
     * field constraints without importing data from LDAP (for example if the application requires Username
512
     * through a Validator). Even though unwritten, it still must have the GUID set.
513
     *
514
     * Constraints:
515
     * - GUID of the member must have already been set, for integrity reasons we don't allow it to change here.
516
     *
517
     * @param Member $member
518
     * @param array|null $data If passed, this is pre-existing AD attribute data to update the Member with.
519
     *            If not given, the data will be looked up by the user's GUID.
520
     * @param bool $updateGroups controls whether to run the resource-intensive group update function as well. This is
521
     *                          skipped during login to reduce load.
522
     * @return bool
523
     * @internal param $Member
524
     */
525
    public function updateMemberFromLDAP(Member $member, $data = null, $updateGroups = true)
526
    {
527
        if (!$this->enabled()) {
528
            return false;
529
        }
530
531
        if (!$member->GUID) {
532
            $this->getLogger()->debug(sprintf('Cannot update Member ID %s, GUID not set', $member->ID));
533
            return false;
534
        }
535
536
        if (!$data) {
537
            $data = $this->getUserByGUID($member->GUID);
538
            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...
539
                $this->getLogger()->debug(sprintf('Could not retrieve data for user. GUID: %s', $member->GUID));
540
                return false;
541
            }
542
        }
543
544
        $member->IsExpired = ($data['useraccountcontrol'] & 2) == 2;
545
        $member->LastSynced = (string)DBDatetime::now();
546
547
        foreach ($member->config()->ldap_field_mappings as $attribute => $field) {
548
            if (!isset($data[$attribute])) {
549
                $this->getLogger()->notice(
550
                    sprintf(
551
                        'Attribute %s configured in Member.ldap_field_mappings, ' .
552
                                'but no available attribute in AD data (GUID: %s, Member ID: %s)',
553
                        $attribute,
554
                        $data['objectguid'],
555
                        $member->ID
556
                    )
557
                );
558
559
                continue;
560
            }
561
562
            if ($attribute == 'thumbnailphoto') {
563
                $imageClass = $member->getRelationClass($field);
564
                if ($imageClass !== Image::class
565
                    && !is_subclass_of($imageClass, Image::class)
566
                ) {
567
                    $this->getLogger()->debug(
568
                        sprintf(
569
                            'Member field %s configured for thumbnailphoto AD attribute, but it isn\'t a ' .
570
                            'valid relation to an Image class',
571
                            $field
572
                        )
573
                    );
574
575
                    continue;
576
                }
577
578
                // Remove existing image if it exists
579
                $existingObj = $member->getComponent($field);
580
                if ($existingObj && $existingObj->exists()) {
581
                    $existingObj->delete();
582
                }
583
584
                // Setup variables
585
                $thumbnailFolder = Folder::find_or_make($member->config()->ldap_thumbnail_path);
586
                $filename = sprintf('thumbnailphoto-%s.jpg', $data['samaccountname']);
587
                $upload = Upload::create();
588
                $upload->setReplaceFile(true);
589
                $info = new finfo(FILEINFO_MIME_TYPE);
590
591
                // Write thumbnail to tmp location
592
                $tmpFilename = tempnam(sys_get_temp_dir(), 'thumbnailphoto');
593
                file_put_contents($tmpFilename, $data[$attribute]);
594
595
                $tmpUpload = [
596
                    'name' => $filename,
597
                    'type' => $info->buffer($data[$attribute]),
598
                    'tmp_name' => $tmpFilename,
599
                    'error' => 0,
600
                    'size' => filesize($tmpFilename)
601
                ];
602
603
                // Disable is_uploaded_file() check in Upload_Validator
604
                $oldUseIsUploadedFile = Upload_Validator::config()->get('use_is_uploaded_file');
605
                Upload_Validator::config()->set('use_is_uploaded_file', false);
606
                if(!$upload->validate($tmpUpload)) {
607
                    $errors = $upload->getErrors();
608
                    $message = array_shift($errors);
609
                    $message = sprintf('Error while populating from file %s: %s', $tmpFilename, $message);
610
                    unlink($tmpFilename); // Remove the old tmp file
611
                    throw new Exception($message);
612
                }
613
614
                $fileClass = File::get_class_for_file_extension(File::get_file_extension($tmpUpload['name']));
615
616
                /** @var File $file */
617
                $file = Injector::inst()->create($fileClass);
618
                $uploadResult = $upload->loadIntoFile($tmpUpload, $file, $thumbnailFolder->getFilename());
619
620
                if (!$uploadResult) {
621
                    throw new Exception(sprintf('Failed to load file %s', $tmpFilename));
622
                }
623
                Upload_Validator::config()->set('use_is_uploaded_file', $oldUseIsUploadedFile);
624
625
                $file->ParentID = $thumbnailFolder->ID;
626
                $file->write();
627
                $file->publishRecursive();
628
                unlink($tmpFilename); // Remove the old tmp file
629
630
                if ($file->exists()) {
631
                    $relationField = sprintf('%sID', $field);
632
                    $member->{$relationField} = $file->ID;
633
                }
634
            } else {
635
                $member->$field = $data[$attribute];
636
            }
637
        }
638
639
        // if a default group was configured, ensure the user is in that group
640
        if ($this->config()->default_group) {
641
            $group = Group::get()->filter('Code', $this->config()->default_group)->limit(1)->first();
642
            if (!($group && $group->exists())) {
643
                $this->getLogger()->debug(
644
                    sprintf(
645
                        'LDAPService.default_group misconfiguration! There is no such group with Code = \'%s\'',
646
                        $this->config()->default_group
647
                    )
648
                );
649
            } else {
650
                // The member must exist before we can use it as a relation:
651
                if (!$member->exists()) {
652
                    $member->write();
653
                }
654
655
                $group->Members()->add($member, [
656
                    'IsImportedFromLDAP' => '1'
657
                ]);
658
            }
659
        }
660
661
        // this is to keep track of which groups the user gets mapped to
662
        // and we'll use that later to remove them from any groups that they're no longer mapped to
663
        $mappedGroupIDs = [];
0 ignored issues
show
Unused Code introduced by
The assignment to $mappedGroupIDs is dead and can be removed.
Loading history...
664
665
        // Member must have an ID before manipulating Groups, otherwise they will not be added correctly.
666
        // However we cannot do a full ->write before the groups are associated, because this will upsync
667
        // the Member, in effect deleting all their LDAP group associations!
668
        $member->writeWithoutSync();
669
670
        if ($updateGroups) {
671
            $this->updateMemberGroups($data, $member);
672
        }
673
674
        // This will throw an exception if there are two distinct GUIDs with the same email address.
675
        // We are happy with a raw 500 here at this stage.
676
        $member->write();
677
    }
678
679
    /**
680
     * Ensure the user is mapped to any applicable groups.
681
     * @param array $data
682
     * @param Member $member
683
     */
684
    public function updateMemberGroups($data, Member $member)
685
    {
686
        if (isset($data['memberof'])) {
687
            $ldapGroups = is_array($data['memberof']) ? $data['memberof'] : [$data['memberof']];
688
            foreach ($ldapGroups as $groupDN) {
689
                foreach (LDAPGroupMapping::get() as $mapping) {
690
                    if (!$mapping->DN) {
691
                        $this->getLogger()->debug(
692
                            sprintf(
693
                                'LDAPGroupMapping ID %s is missing DN field. Skipping',
694
                                $mapping->ID
695
                            )
696
                        );
697
                        continue;
698
                    }
699
700
                    // the user is a direct member of group with a mapping, add them to the SS group.
701
                    if ($mapping->DN == $groupDN) {
702
                        $group = $mapping->Group();
703
                        if ($group && $group->exists()) {
704
                            $group->Members()->add($member, [
705
                                'IsImportedFromLDAP' => '1'
706
                            ]);
707
                            $mappedGroupIDs[] = $mapping->GroupID;
708
                        }
709
                    }
710
711
                    // the user *might* be a member of a nested group provided the scope of the mapping
712
                    // is to include the entire subtree. Check all those mappings and find the LDAP child groups
713
                    // to see if they are a member of one of those. If they are, add them to the SS group
714
                    if ($mapping->Scope == 'Subtree') {
715
                        $childGroups = $this->getNestedGroups($mapping->DN, ['dn']);
716
                        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...
717
                            continue;
718
                        }
719
720
                        foreach ($childGroups as $childGroupDN => $childGroupRecord) {
721
                            if ($childGroupDN == $groupDN) {
722
                                $group = $mapping->Group();
723
                                if ($group && $group->exists()) {
724
                                    $group->Members()->add($member, [
725
                                        'IsImportedFromLDAP' => '1'
726
                                    ]);
727
                                    $mappedGroupIDs[] = $mapping->GroupID;
728
                                }
729
                            }
730
                        }
731
                    }
732
                }
733
            }
734
        }
735
736
        // remove the user from any previously mapped groups, where the mapping has since been removed
737
        $groupRecords = DB::query(
738
            sprintf(
739
                'SELECT "GroupID" FROM "Group_Members" WHERE "IsImportedFromLDAP" = 1 AND "MemberID" = %s',
740
                $member->ID
741
            )
742
        );
743
744
        if (!empty($mappedGroupIDs)) {
745
            foreach ($groupRecords as $groupRecord) {
746
                if (!in_array($groupRecord['GroupID'], $mappedGroupIDs)) {
747
                    $group = Group::get()->byId($groupRecord['GroupID']);
748
                    // Some groups may no longer exist. SilverStripe does not clean up join tables.
749
                    if ($group) {
750
                        $group->Members()->remove($member);
751
                    }
752
                }
753
            }
754
        }
755
    }
756
757
    /**
758
     * Sync a specific Group by updating it with LDAP data.
759
     *
760
     * @param Group $group An existing Group or a new Group object
761
     * @param array $data LDAP group object data
762
     *
763
     * @return bool
764
     */
765
    public function updateGroupFromLDAP(Group $group, $data)
766
    {
767
        if (!$this->enabled()) {
768
            return false;
769
        }
770
771
        // Synchronise specific guaranteed fields.
772
        $group->Code = $data['samaccountname'];
773
        $group->Title = $data['samaccountname'];
774
        if (!empty($data['description'])) {
775
            $group->Description = $data['description'];
776
        }
777
        $group->DN = $data['dn'];
778
        $group->LastSynced = (string)DBDatetime::now();
779
        $group->write();
780
781
        // Mappings on this group are automatically maintained to contain just the group's DN.
782
        // First, scan through existing mappings and remove ones that are not matching (in case the group moved).
783
        $hasCorrectMapping = false;
784
        foreach ($group->LDAPGroupMappings() as $mapping) {
785
            if ($mapping->DN === $data['dn']) {
786
                // This is the correct mapping we want to retain.
787
                $hasCorrectMapping = true;
788
            } else {
789
                $mapping->delete();
790
            }
791
        }
792
793
        // Second, if the main mapping was not found, add it in.
794
        if (!$hasCorrectMapping) {
795
            $mapping = new LDAPGroupMapping();
796
            $mapping->DN = $data['dn'];
0 ignored issues
show
Bug Best Practice introduced by
The property DN does not exist on SilverStripe\LDAP\Model\LDAPGroupMapping. Since you implemented __set, consider adding a @property annotation.
Loading history...
797
            $mapping->write();
798
            $group->LDAPGroupMappings()->add($mapping);
799
        }
800
    }
801
802
    /**
803
     * Creates a new LDAP user from the passed Member record.
804
     * Note that the Member record must have a non-empty Username field for this to work.
805
     *
806
     * @param Member $member
807
     * @throws ValidationException
808
     * @throws Exception
809
     */
810
    public function createLDAPUser(Member $member)
811
    {
812
        if (!$this->enabled()) {
813
            return;
814
        }
815
        if (empty($member->Username)) {
816
            throw new ValidationException('Member missing Username. Cannot create LDAP user');
817
        }
818
        if (!$this->config()->new_users_dn) {
819
            throw new Exception('LDAPService::new_users_dn must be configured to create LDAP users');
820
        }
821
822
        // Normalise username to lowercase to ensure we don't have duplicates of different cases
823
        $member->Username = strtolower($member->Username);
824
825
        // Create user in LDAP using available information.
826
        $dn = sprintf('CN=%s,%s', $member->Username, $this->config()->new_users_dn);
827
828
        try {
829
            $this->add($dn, [
830
                'objectclass' => 'user',
831
                'cn' => $member->Username,
832
                'accountexpires' => '9223372036854775807',
833
                'useraccountcontrol' => '66048',
834
                'userprincipalname' => sprintf(
835
                    '%s@%s',
836
                    $member->Username,
837
                    $this->gateway->config()->options['accountDomainName']
838
                ),
839
            ]);
840
        } catch (Exception $e) {
841
            throw new ValidationException('LDAP synchronisation failure: ' . $e->getMessage());
842
        }
843
844
        $user = $this->getUserByUsername($member->Username);
845
        if (empty($user['objectguid'])) {
846
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
847
        }
848
849
        // Creation was successful, mark the user as LDAP managed by setting the GUID.
850
        $member->GUID = $user['objectguid'];
851
    }
852
853
    /**
854
     * Creates a new LDAP group from the passed Group record.
855
     *
856
     * @param Group $group
857
     * @throws ValidationException
858
     */
859
    public function createLDAPGroup(Group $group)
860
    {
861
        if (!$this->enabled()) {
862
            return;
863
        }
864
        if (empty($group->Title)) {
865
            throw new ValidationException('Group missing Title. Cannot create LDAP group');
866
        }
867
        if (!$this->config()->new_groups_dn) {
868
            throw new Exception('LDAPService::new_groups_dn must be configured to create LDAP groups');
869
        }
870
871
        // LDAP isn't really meant to distinguish between a Title and Code. Squash them.
872
        $group->Code = $group->Title;
873
874
        $dn = sprintf('CN=%s,%s', $group->Title, $this->config()->new_groups_dn);
875
        try {
876
            $this->add($dn, [
877
                'objectclass' => 'group',
878
                'cn' => $group->Title,
879
                'name' => $group->Title,
880
                'samaccountname' => $group->Title,
881
                'description' => $group->Description,
882
                'distinguishedname' => $dn
883
            ]);
884
        } catch (Exception $e) {
885
            throw new ValidationException('LDAP group creation failure: ' . $e->getMessage());
886
        }
887
888
        $data = $this->getGroupByDN($dn);
889
        if (empty($data['objectguid'])) {
890
            throw new ValidationException(
891
                new ValidationResult(
892
                    false,
893
                    'LDAP group creation failure: group might have been created in LDAP. GUID is missing.'
894
                )
895
            );
896
        }
897
898
        // Creation was successful, mark the group as LDAP managed by setting the GUID.
899
        $group->GUID = $data['objectguid'];
900
        $group->DN = $data['dn'];
901
    }
902
903
    /**
904
     * Update the Member data back to the corresponding LDAP user object.
905
     *
906
     * @param Member $member
907
     * @throws ValidationException
908
     */
909
    public function updateLDAPFromMember(Member $member)
910
    {
911
        if (!$this->enabled()) {
912
            return;
913
        }
914
        if (!$member->GUID) {
915
            throw new ValidationException('Member missing GUID. Cannot update LDAP user');
916
        }
917
918
        $data = $this->getUserByGUID($member->GUID);
919
        if (empty($data['objectguid'])) {
920
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
921
        }
922
923
        if (empty($member->Username)) {
924
            throw new ValidationException('Member missing Username. Cannot update LDAP user');
925
        }
926
927
        $dn = $data['distinguishedname'];
928
929
        // Normalise username to lowercase to ensure we don't have duplicates of different cases
930
        $member->Username = strtolower($member->Username);
931
932
        try {
933
            // If the common name (cn) has changed, we need to ensure they've been moved
934
            // to the new DN, to avoid any clashes between user objects.
935
            if ($data['cn'] != $member->Username) {
936
                $newDn = sprintf('CN=%s,%s', $member->Username, preg_replace('/^CN=(.+?),/', '', $dn));
937
                $this->move($dn, $newDn);
938
                $dn = $newDn;
939
            }
940
        } catch (Exception $e) {
941
            throw new ValidationException('LDAP move failure: '.$e->getMessage());
942
        }
943
944
        try {
945
            $attributes = [
946
                'displayname' => sprintf('%s %s', $member->FirstName, $member->Surname),
947
                'name' => sprintf('%s %s', $member->FirstName, $member->Surname),
948
                'userprincipalname' => sprintf(
949
                    '%s@%s',
950
                    $member->Username,
951
                    $this->gateway->config()->options['accountDomainName']
952
                ),
953
            ];
954
            foreach ($member->config()->ldap_field_mappings as $attribute => $field) {
955
                $relationClass = $member->getRelationClass($field);
956
                if ($relationClass) {
957
                    // todo no support for writing back relations yet.
958
                } else {
959
                    $attributes[$attribute] = $member->$field;
960
                }
961
            }
962
963
            $this->update($dn, $attributes);
964
        } catch (Exception $e) {
965
            throw new ValidationException('LDAP synchronisation failure: '.$e->getMessage());
966
        }
967
    }
968
969
    /**
970
     * Ensure the user belongs to the correct groups in LDAP from their membership
971
     * to local LDAP mapped SilverStripe groups.
972
     *
973
     * This also removes them from LDAP groups if they've been taken out of one.
974
     * It will not affect group membership of non-mapped groups, so it will
975
     * not touch such internal AD groups like "Domain Users".
976
     *
977
     * @param Member $member
978
     * @throws ValidationException
979
     */
980
    public function updateLDAPGroupsForMember(Member $member)
981
    {
982
        if (!$this->enabled()) {
983
            return;
984
        }
985
        if (!$member->GUID) {
986
            throw new ValidationException('Member missing GUID. Cannot update LDAP user');
987
        }
988
989
        $addGroups = [];
990
        $removeGroups = [];
991
992
        $user = $this->getUserByGUID($member->GUID);
993
        if (empty($user['objectguid'])) {
994
            throw new ValidationException('LDAP update failure: user missing GUID');
995
        }
996
997
        // If a user belongs to a single group, this comes through as a string.
998
        // Normalise to a array so it's consistent.
999
        $existingGroups = !empty($user['memberof']) ? $user['memberof'] : [];
1000
        if ($existingGroups && is_string($existingGroups)) {
1001
            $existingGroups = [$existingGroups];
1002
        }
1003
1004
        foreach ($member->Groups() as $group) {
1005
            if (!$group->GUID) {
1006
                continue;
1007
            }
1008
1009
            // mark this group as something we need to ensure the user belongs to in LDAP.
1010
            $addGroups[] = $group->DN;
1011
        }
1012
1013
        // Which existing LDAP groups are not in the add groups? We'll check these groups to
1014
        // see if the user should be removed from any of them.
1015
        $remainingGroups = array_diff($existingGroups, $addGroups);
1016
1017
        foreach ($remainingGroups as $groupDn) {
1018
            // We only want to be removing groups we have a local Group mapped to. Removing
1019
            // membership for anything else would be bad!
1020
            $group = Group::get()->filter('DN', $groupDn)->first();
1021
            if (!$group || !$group->exists()) {
1022
                continue;
1023
            }
1024
1025
            // this group should be removed from the user's memberof attribute, as it's been removed.
1026
            $removeGroups[] = $groupDn;
1027
        }
1028
1029
        // go through the groups we want the user to be in and ensure they're in them.
1030
        foreach ($addGroups as $groupDn) {
1031
            $this->addLDAPUserToGroup($user['distinguishedname'], $groupDn);
1032
        }
1033
1034
        // go through the groups we _don't_ want the user to be in and ensure they're taken out of them.
1035
        foreach ($removeGroups as $groupDn) {
1036
            $members = $this->getLDAPGroupMembers($groupDn);
1037
1038
            // remove the user from the members data.
1039
            if (in_array($user['distinguishedname'], $members)) {
1040
                foreach ($members as $i => $dn) {
1041
                    if ($dn == $user['distinguishedname']) {
1042
                        unset($members[$i]);
1043
                    }
1044
                }
1045
            }
1046
1047
            try {
1048
                $this->update($groupDn, ['member' => $members]);
1049
            } catch (Exception $e) {
1050
                throw new ValidationException('LDAP group membership remove failure: ' . $e->getMessage());
1051
            }
1052
        }
1053
    }
1054
1055
    /**
1056
     * Add LDAP user by DN to LDAP group.
1057
     *
1058
     * @param string $userDn
1059
     * @param string $groupDn
1060
     * @throws Exception
1061
     */
1062
    public function addLDAPUserToGroup($userDn, $groupDn)
1063
    {
1064
        $members = $this->getLDAPGroupMembers($groupDn);
1065
1066
        // this user is already in the group, no need to do anything.
1067
        if (in_array($userDn, $members)) {
1068
            return;
1069
        }
1070
1071
        $members[] = $userDn;
1072
1073
        try {
1074
            $this->update($groupDn, ['member' => $members]);
1075
        } catch (Exception $e) {
1076
            throw new ValidationException('LDAP group membership add failure: ' . $e->getMessage());
1077
        }
1078
    }
1079
1080
    /**
1081
     * Change a members password on the AD. Works with ActiveDirectory compatible services that saves the
1082
     * password in the `unicodePwd` attribute.
1083
     *
1084
     * @todo Use the Zend\Ldap\Attribute::setPassword functionality to create a password in
1085
     * an abstract way, so it works on other LDAP directories, not just Active Directory.
1086
     *
1087
     * Ensure that the LDAP bind:ed user can change passwords and that the connection is secure.
1088
     *
1089
     * @param Member $member
1090
     * @param string $password
1091
     * @param string|null $oldPassword Supply old password to perform a password change (as opposed to password reset)
1092
     * @return ValidationResult
1093
     */
1094
    public function setPassword(Member $member, $password, $oldPassword = null)
1095
    {
1096
        $validationResult = ValidationResult::create();
1097
1098
        $this->extend('onBeforeSetPassword', $member, $password, $validationResult);
1099
1100
        if (!$member->GUID) {
1101
            $this->getLogger()->debug(sprintf('Cannot update Member ID %s, GUID not set', $member->ID));
1102
            $validationResult->addError(
1103
                _t(
1104
                    'SilverStripe\\LDAP\\Authenticators\\LDAPAuthenticator.NOUSER',
1105
                    'Your account hasn\'t been setup properly, please contact an administrator.'
1106
                )
1107
            );
1108
            return $validationResult;
1109
        }
1110
1111
        $userData = $this->getUserByGUID($member->GUID);
1112
        if (empty($userData['distinguishedname'])) {
1113
            $validationResult->addError(
1114
                _t(
1115
                    'SilverStripe\\LDAP\\Authenticators\\LDAPAuthenticator.NOUSER',
1116
                    'Your account hasn\'t been setup properly, please contact an administrator.'
1117
                )
1118
            );
1119
            return $validationResult;
1120
        }
1121
1122
        try {
1123
            if (!empty($oldPassword)) {
1124
                $this->gateway->changePassword($userData['distinguishedname'], $password, $oldPassword);
1125
            } elseif ($this->config()->password_history_workaround) {
1126
                $this->passwordHistoryWorkaround($userData['distinguishedname'], $password);
1127
            } else {
1128
                $this->gateway->resetPassword($userData['distinguishedname'], $password);
1129
            }
1130
            $this->extend('onAfterSetPassword', $member, $password, $validationResult);
1131
        } catch (Exception $e) {
1132
            $validationResult->addError($e->getMessage());
1133
        }
1134
1135
        return $validationResult;
1136
    }
1137
1138
    /**
1139
     * Delete an LDAP user mapped to the Member record
1140
     * @param Member $member
1141
     * @throws ValidationException
1142
     */
1143
    public function deleteLDAPMember(Member $member)
1144
    {
1145
        if (!$this->enabled()) {
1146
            return;
1147
        }
1148
        if (!$member->GUID) {
1149
            throw new ValidationException('Member missing GUID. Cannot delete LDAP user');
1150
        }
1151
        $data = $this->getUserByGUID($member->GUID);
1152
        if (empty($data['distinguishedname'])) {
1153
            throw new ValidationException('LDAP delete failure: could not find distinguishedname attribute');
1154
        }
1155
1156
        try {
1157
            $this->delete($data['distinguishedname']);
1158
        } catch (Exception $e) {
1159
            throw new ValidationException('LDAP delete user failed: ' . $e->getMessage());
1160
        }
1161
    }
1162
1163
    /**
1164
     * A simple proxy to LDAP update operation.
1165
     *
1166
     * @param string $dn Location to add the entry at.
1167
     * @param array $attributes A simple associative array of attributes.
1168
     */
1169
    public function update($dn, array $attributes)
1170
    {
1171
        $this->gateway->update($dn, $attributes);
1172
    }
1173
1174
    /**
1175
     * A simple proxy to LDAP delete operation.
1176
     *
1177
     * @param string $dn Location of object to delete
1178
     * @param bool $recursively Recursively delete nested objects?
1179
     */
1180
    public function delete($dn, $recursively = false)
1181
    {
1182
        $this->gateway->delete($dn, $recursively);
1183
    }
1184
1185
    /**
1186
     * A simple proxy to LDAP copy/delete operation.
1187
     *
1188
     * @param string $fromDn
1189
     * @param string $toDn
1190
     * @param bool $recursively Recursively move nested objects?
1191
     */
1192
    public function move($fromDn, $toDn, $recursively = false)
1193
    {
1194
        $this->gateway->move($fromDn, $toDn, $recursively);
1195
    }
1196
1197
    /**
1198
     * A simple proxy to LDAP add operation.
1199
     *
1200
     * @param string $dn Location to add the entry at.
1201
     * @param array $attributes A simple associative array of attributes.
1202
     */
1203
    public function add($dn, array $attributes)
1204
    {
1205
        $this->gateway->add($dn, $attributes);
1206
    }
1207
1208
    /**
1209
     * @param string $dn Distinguished name of the user
1210
     * @param string $password New password.
1211
     * @throws Exception
1212
     */
1213
    private function passwordHistoryWorkaround($dn, $password)
1214
    {
1215
        $generator = new RandomGenerator();
1216
        // 'Aa1' is there to satisfy the complexity criterion.
1217
        $tempPassword = sprintf('Aa1%s', substr($generator->randomToken('sha1'), 0, 21));
1218
        $this->gateway->resetPassword($dn, $tempPassword);
1219
        $this->gateway->changePassword($dn, $password, $tempPassword);
1220
    }
1221
1222
    /**
1223
     * Get a logger
1224
     *
1225
     * @return LoggerInterface
1226
     */
1227
    public function getLogger()
1228
    {
1229
        return Injector::inst()->get(LoggerInterface::class);
1230
    }
1231
}
1232