Completed
Push — master ( 90581b...7896ee )
by Daniel
01:58
created

code/services/LDAPService.php (1 issue)

Labels
Severity

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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