Completed
Push — master ( ea72ed...9b94ca )
by Sean
04:09 queued 01:51
created

src/Services/LDAPService.php (4 issues)

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
namespace SilverStripe\ActiveDirectory\Services;
4
5
use Exception;
6
use SilverStripe\ActiveDirectory\Model\LDAPGroupMapping;
7
use SilverStripe\Assets\Filesystem;
8
use SilverStripe\Core\Cache;
9
use SilverStripe\Core\Config\Config;
10
use SilverStripe\Core\Flushable;
11
use SilverStripe\Core\Injector\Injector;
12
use SilverStripe\Core\Object;
13
use SilverStripe\ORM\DB;
14
use SilverStripe\ORM\FieldType\DBDatetime;
15
use SilverStripe\ORM\ValidationException;
16
use SilverStripe\ORM\ValidationResult;
17
use SilverStripe\Security\Group;
18
use SilverStripe\Security\Member;
19
use SilverStripe\Security\RandomGenerator;
20
use Zend_Cache;
21
use Zend\Ldap\Ldap;
22
23
/**
24
 * Class LDAPService
25
 *
26
 * Provides LDAP operations expressed in terms of the SilverStripe domain.
27
 * All other modules should access LDAP through this class.
28
 *
29
 * This class builds on top of LDAPGateway's detailed code by adding:
30
 * - caching
31
 * - data aggregation and restructuring from multiple lower-level calls
32
 * - error handling
33
 *
34
 * LDAPService relies on Zend LDAP module's data structures for some parameters and some return values.
35
 *
36
 * @package activedirectory
37
 */
38
class LDAPService extends Object implements Flushable
39
{
40
    /**
41
     * @var array
42
     */
43
    private static $dependencies = [
44
        'gateway' => '%$SilverStripe\\ActiveDirectory\\Model\\LDAPGateway'
45
    ];
46
47
    /**
48
     * If configured, only user objects within these locations will be exposed to this service.
49
     *
50
     * @var array
51
     * @config
52
     */
53
    private static $users_search_locations = [];
54
55
    /**
56
     * If configured, only group objects within these locations will be exposed to this service.
57
     * @var array
58
     *
59
     * @config
60
     */
61
    private static $groups_search_locations = [];
62
63
    /**
64
     * Location to create new users in (distinguished name).
65
     * @var string
66
     *
67
     * @config
68
     */
69
    private static $new_users_dn;
70
71
    /**
72
     * Location to create new groups in (distinguished name).
73
     * @var string
74
     *
75
     * @config
76
     */
77
    private static $new_groups_dn;
0 ignored issues
show
The property $new_groups_dn is not used and could be removed.

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

Loading history...
78
79
    /**
80
     * @var array
81
     */
82
    private static $_cache_nested_groups = [];
83
84
    /**
85
     * If this is configured to a "Code" value of a {@link Group} in SilverStripe, the user will always
86
     * be added to this group's membership when imported, regardless of any sort of group mappings.
87
     *
88
     * @var string
89
     * @config
90
     */
91
    private static $default_group;
92
93
    /**
94
     * For samba4 directory, there is no way to enforce password history on password resets.
95
     * This only happens with changePassword (which requires the old password).
96
     * This works around it by making the old password up and setting it administratively.
97
     *
98
     * A cleaner fix would be to use the LDAP_SERVER_POLICY_HINTS_OID connection flag,
99
     * but it's not implemented in samba https://bugzilla.samba.org/show_bug.cgi?id=12020
100
     *
101
     * @var bool
102
     */
103
    private static $password_history_workaround = false;
104
105
    /**
106
     * Get the cache object used for LDAP results. Note that the default lifetime set here
107
     * is 8 hours, but you can change that by calling Cache::set_lifetime('ldap', <lifetime in seconds>)
108
     *
109
     * @return Zend_Cache_Frontend
110
     */
111
    public static function get_cache()
112
    {
113
        return Cache::factory('ldap', 'Output', [
114
            'automatic_serialization' => true,
115
            'lifetime' => 28800
116
        ]);
117
    }
118
119
    /**
120
     * Flushes out the LDAP results cache when flush=1 is called.
121
     */
122
    public static function flush()
123
    {
124
        $cache = self::get_cache();
125
        $cache->clean(Zend_Cache::CLEANING_MODE_ALL);
126
    }
127
128
    /**
129
     * @var LDAPGateway
130
     */
131
    public $gateway;
132
133
    /**
134
     * Setter for gateway. Useful for overriding the gateway with a fake for testing.
135
     * @var LDAPGateway
136
     */
137
    public function setGateway($gateway)
138
    {
139
        $this->gateway = $gateway;
140
    }
141
142
    /**
143
     * Checkes whether or not the service is enabled.
144
     *
145
     * @return bool
146
     */
147
    public function enabled()
148
    {
149
        $options = Config::inst()->get('SilverStripe\\ActiveDirectory\\Model\\LDAPGateway', 'options');
150
        return !empty($options);
151
    }
152
153
    /**
154
     * Authenticate the given username and password with LDAP.
155
     *
156
     * @param string $username
157
     * @param string $password
158
     *
159
     * @return array
160
     */
161
    public function authenticate($username, $password)
162
    {
163
        $result = $this->gateway->authenticate($username, $password);
164
        $messages = $result->getMessages();
165
166
        // all messages beyond the first one are for debugging and
167
        // not suitable to display to the user.
168
        foreach ($messages as $i => $message) {
169
            if ($i > 0) {
170
                $this->getLogger()->debug(str_replace("\n", "\n  ", $message));
171
            }
172
        }
173
174
        $message = $messages[0]; // first message is user readable, suitable for showing on login form
175
176
        // show better errors than the defaults for various status codes returned by LDAP
177 View Code Duplication
        if (!empty($messages[1]) && strpos($messages[1], 'NT_STATUS_ACCOUNT_LOCKED_OUT') !== false) {
178
            $message = _t(
179
                'LDAPService.ACCOUNTLOCKEDOUT',
180
                'Your account has been temporarily locked because of too many failed login attempts. ' .
181
                'Please try again later.'
182
            );
183
        }
184 View Code Duplication
        if (!empty($messages[1]) && strpos($messages[1], 'NT_STATUS_LOGON_FAILURE') !== false) {
185
            $message = _t(
186
                'LDAPService.INVALIDCREDENTIALS',
187
                'The provided details don\'t seem to be correct. Please try again.'
188
            );
189
        }
190
191
        return [
192
            'success' => $result->getCode() === 1,
193
            'identity' => $result->getIdentity(),
194
            'message' => $message
195
        ];
196
    }
197
198
    /**
199
     * Return all nodes (organizational units, containers, and domains) within the current base DN.
200
     *
201
     * @param boolean $cached Cache the results from AD, so that subsequent calls are faster. Enabled by default.
202
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
203
     * @return array
204
     */
205
    public function getNodes($cached = true, $attributes = [])
206
    {
207
        $cache = self::get_cache();
208
        $results = $cache->load('nodes' . md5(implode('', $attributes)));
209
210
        if (!$results || !$cached) {
211
            $results = [];
212
            $records = $this->gateway->getNodes(null, Ldap::SEARCH_SCOPE_SUB, $attributes);
213
            foreach ($records as $record) {
214
                $results[$record['dn']] = $record;
215
            }
216
217
            $cache->save($results);
218
        }
219
220
        return $results;
221
    }
222
223
    /**
224
     * Return all AD groups in configured search locations, including all nested groups.
225
     * Uses groups_search_locations if defined, otherwise falls back to NULL, which tells LDAPGateway
226
     * to use the default baseDn defined in the connection.
227
     *
228
     * @param boolean $cached Cache the results from AD, so that subsequent calls are faster. Enabled by default.
229
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
230
     * @param string $indexBy Attribute to use as an index.
231
     * @return array
232
     */
233
    public function getGroups($cached = true, $attributes = [], $indexBy = 'dn')
234
    {
235
        $searchLocations = $this->config()->groups_search_locations ?: [null];
236
        $cache = self::get_cache();
237
        $results = $cache->load('groups' . md5(implode('', array_merge($searchLocations, $attributes))));
238
239
        if (!$results || !$cached) {
240
            $results = [];
241 View Code Duplication
            foreach ($searchLocations as $searchLocation) {
0 ignored issues
show
This code seems to be duplicated across your project.

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

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

Loading history...
242
                $records = $this->gateway->getGroups($searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
243
                if (!$records) {
244
                    continue;
245
                }
246
247
                foreach ($records as $record) {
248
                    $results[$record[$indexBy]] = $record;
249
                }
250
            }
251
252
            $cache->save($results);
253
        }
254
255
        return $results;
256
    }
257
258
    /**
259
     * Return all member groups (and members of those, recursively) underneath a specific group DN.
260
     * Note that these get cached in-memory per-request for performance to avoid re-querying for the same results.
261
     *
262
     * @param string $dn
263
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
264
     * @return array
265
     */
266
    public function getNestedGroups($dn, $attributes = [])
267
    {
268
        if (isset(self::$_cache_nested_groups[$dn])) {
269
            return self::$_cache_nested_groups[$dn];
270
        }
271
272
        $searchLocations = $this->config()->groups_search_locations ?: [null];
273
        $results = [];
274 View Code Duplication
        foreach ($searchLocations as $searchLocation) {
0 ignored issues
show
This code seems to be duplicated across your project.

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

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

Loading history...
275
            $records = $this->gateway->getNestedGroups($dn, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
276
            foreach ($records as $record) {
277
                $results[$record['dn']] = $record;
278
            }
279
        }
280
281
        self::$_cache_nested_groups[$dn] = $results;
282
        return $results;
283
    }
284
285
    /**
286
     * Get a particular AD group's data given a GUID.
287
     *
288
     * @param string $guid
289
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
290
     * @return array
291
     */
292 View Code Duplication
    public function getGroupByGUID($guid, $attributes = [])
293
    {
294
        $searchLocations = $this->config()->groups_search_locations ?: [null];
295
        foreach ($searchLocations as $searchLocation) {
296
            $records = $this->gateway->getGroupByGUID($guid, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
297
            if ($records) {
298
                return $records[0];
299
            }
300
        }
301
    }
302
303
    /**
304
     * Get a particular AD group's data given a DN.
305
     *
306
     * @param string $dn
307
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
308
     * @return array
309
     */
310 View Code Duplication
    public function getGroupByDN($dn, $attributes = [])
311
    {
312
        $searchLocations = $this->config()->groups_search_locations ?: [null];
313
        foreach ($searchLocations as $searchLocation) {
314
            $records = $this->gateway->getGroupByDN($dn, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
315
            if ($records) {
316
                return $records[0];
317
            }
318
        }
319
    }
320
321
    /**
322
     * Return all AD users in configured search locations, including all users in nested groups.
323
     * Uses users_search_locations if defined, otherwise falls back to NULL, which tells LDAPGateway
324
     * to use the default baseDn defined in the connection.
325
     *
326
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
327
     * @return array
328
     */
329
    public function getUsers($attributes = [])
330
    {
331
        $searchLocations = $this->config()->users_search_locations ?: [null];
332
        $results = [];
333
334 View Code Duplication
        foreach ($searchLocations as $searchLocation) {
0 ignored issues
show
This code seems to be duplicated across your project.

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

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

Loading history...
335
            $records = $this->gateway->getUsers($searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
336
            if (!$records) {
337
                continue;
338
            }
339
340
            foreach ($records as $record) {
341
                $results[$record['objectguid']] = $record;
342
            }
343
        }
344
345
        return $results;
346
    }
347
348
    /**
349
     * Get a specific AD user's data given a GUID.
350
     *
351
     * @param string $guid
352
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
353
     * @return array
354
     */
355 View Code Duplication
    public function getUserByGUID($guid, $attributes = [])
356
    {
357
        $searchLocations = $this->config()->users_search_locations ?: [null];
358
        foreach ($searchLocations as $searchLocation) {
359
            $records = $this->gateway->getUserByGUID($guid, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
360
            if ($records) {
361
                return $records[0];
362
            }
363
        }
364
    }
365
366
    /**
367
     * Get a specific AD user's data given a DN.
368
     *
369
     * @param string $dn
370
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
371
     *
372
     * @return array
373
     */
374 View Code Duplication
    public function getUserByDN($dn, $attributes = [])
375
    {
376
        $searchLocations = $this->config()->users_search_locations ?: [null];
377
        foreach ($searchLocations as $searchLocation) {
378
            $records = $this->gateway->getUserByDN($dn, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
379
            if ($records) {
380
                return $records[0];
381
            }
382
        }
383
    }
384
385
    /**
386
     * Get a specific user's data given an email.
387
     *
388
     * @param string $email
389
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
390
     * @return array
391
     */
392 View Code Duplication
    public function getUserByEmail($email, $attributes = [])
393
    {
394
        $searchLocations = $this->config()->users_search_locations ?: [null];
395
        foreach ($searchLocations as $searchLocation) {
396
            $records = $this->gateway->getUserByEmail($email, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
397
            if ($records) {
398
                return $records[0];
399
            }
400
        }
401
    }
402
403
    /**
404
     * Get a specific user's data given a username.
405
     *
406
     * @param string $username
407
     * @param array $attributes List of specific AD attributes to return. Empty array means return everything.
408
     * @return array
409
     */
410 View Code Duplication
    public function getUserByUsername($username, $attributes = [])
411
    {
412
        $searchLocations = $this->config()->users_search_locations ?: [null];
413
        foreach ($searchLocations as $searchLocation) {
414
            $records = $this->gateway->getUserByUsername($username, $searchLocation, Ldap::SEARCH_SCOPE_SUB, $attributes);
415
            if ($records) {
416
                return $records[0];
417
            }
418
        }
419
    }
420
421
    /**
422
     * Get a username for an email.
423
     *
424
     * @param string $email
425
     * @return string|null
426
     */
427
    public function getUsernameByEmail($email)
428
    {
429
        $data = $this->getUserByEmail($email);
430
        if (empty($data)) {
431
            return null;
432
        }
433
434
        return $this->gateway->getCanonicalUsername($data);
435
    }
436
437
    /**
438
     * Given a group DN, get the group membership data in LDAP.
439
     *
440
     * @param string $dn
441
     * @return array
442
     */
443
    public function getLDAPGroupMembers($dn)
444
    {
445
        $groupObj = Group::get()->filter('DN', $dn)->first();
446
        $groupData = $this->getGroupByGUID($groupObj->GUID);
447
        $members = !empty($groupData['member']) ? $groupData['member'] : [];
448
        // If a user belongs to a single group, this comes through as a string.
449
        // Normalise to a array so it's consistent.
450
        if ($members && is_string($members)) {
451
            $members = [$members];
452
        }
453
454
        return $members;
455
    }
456
457
    /**
458
     * Update the current Member record with data from LDAP.
459
     *
460
     * Constraints:
461
     * - Member *must* be in the database before calling this as it will need the ID to be mapped to a {@link Group}.
462
     * - GUID of the member must have already been set, for integrity reasons we don't allow it to change here.
463
     *
464
     * @param Member
465
     * @param array|null $data If passed, this is pre-existing AD attribute data to update the Member with.
466
     *            If not given, the data will be looked up by the user's GUID.
467
     * @return bool
468
     */
469
    public function updateMemberFromLDAP(Member $member, $data = null)
470
    {
471
        if (!$this->enabled()) {
472
            return false;
473
        }
474
475
        if (!$member->GUID) {
476
            $this->getLogger()->warn(sprintf('Cannot update Member ID %s, GUID not set', $member->ID));
477
            return false;
478
        }
479
480
        if (!$data) {
481
            $data = $this->getUserByGUID($member->GUID);
482
            if (!$data) {
483
                $this->getLogger()->warn(sprintf('Could not retrieve data for user. GUID: %s', $member->GUID));
484
                return false;
485
            }
486
        }
487
488
        $member->IsExpired = ($data['useraccountcontrol'] & 2) == 2;
489
        $member->LastSynced = (string)DBDatetime::now();
490
491
        foreach ($member->config()->ldap_field_mappings as $attribute => $field) {
492
            if (!isset($data[$attribute])) {
493
                $this->getLogger()->notice(
494
                    sprintf(
495
                        'Attribute %s configured in Member.ldap_field_mappings, but no available attribute in AD data (GUID: %s, Member ID: %s)',
496
                        $attribute,
497
                        $data['objectguid'],
498
                        $member->ID
499
                    )
500
                );
501
502
                continue;
503
            }
504
505
            if ($attribute == 'thumbnailphoto') {
506
                $imageClass = $member->getRelationClass($field);
507
                if ($imageClass !== 'SilverStripe\\Assets\\Image'
508
                    && !is_subclass_of($imageClass, 'SilverStripe\\Assets\\Image')
509
                ) {
510
                    $this->getLogger()->warn(
511
                        sprintf(
512
                            'Member field %s configured for thumbnailphoto AD attribute, but it isn\'t a valid relation to an Image class',
513
                            $field
514
                        )
515
                    );
516
517
                    continue;
518
                }
519
520
                $filename = sprintf('thumbnailphoto-%s.jpg', $data['samaccountname']);
521
                $path = ASSETS_DIR . '/' . $member->config()->ldap_thumbnail_path;
522
                $absPath = BASE_PATH . '/' . $path;
523
                if (!file_exists($absPath)) {
524
                    Filesystem::makeFolder($absPath);
525
                }
526
527
                // remove existing record if it exists
528
                $existingObj = $member->getComponent($field);
529
                if ($existingObj && $existingObj->exists()) {
530
                    $existingObj->delete();
531
                }
532
533
                // The image data is provided in raw binary.
534
                file_put_contents($absPath . '/' . $filename, $data[$attribute]);
535
                $record = new $imageClass();
536
                $record->Name = $filename;
537
                $record->Filename = $path . '/' . $filename;
538
                $record->write();
539
540
                $relationField = $field . 'ID';
541
                $member->{$relationField} = $record->ID;
542
            } else {
543
                $member->$field = $data[$attribute];
544
            }
545
        }
546
547
        // if a default group was configured, ensure the user is in that group
548
        if ($this->config()->default_group) {
549
            $group = Group::get()->filter('Code', $this->config()->default_group)->limit(1)->first();
550
            if (!($group && $group->exists())) {
551
                $this->getLogger()->warn(
552
                    sprintf(
553
                        'LDAPService.default_group misconfiguration! There is no such group with Code = \'%s\'',
554
                        $this->config()->default_group
555
                    )
556
                );
557
            } else {
558
                $group->Members()->add($member, [
559
                    'IsImportedFromLDAP' => '1'
560
                ]);
561
            }
562
        }
563
564
        // this is to keep track of which groups the user gets mapped to
565
        // and we'll use that later to remove them from any groups that they're no longer mapped to
566
        $mappedGroupIDs = [];
567
568
        // ensure the user is in any mapped groups
569
        if (isset($data['memberof'])) {
570
            $ldapGroups = is_array($data['memberof']) ? $data['memberof'] : [$data['memberof']];
571
            foreach ($ldapGroups as $groupDN) {
572
                foreach (LDAPGroupMapping::get() as $mapping) {
573
                    if (!$mapping->DN) {
574
                        $this->getLogger()->warn(
575
                            sprintf(
576
                                'LDAPGroupMapping ID %s is missing DN field. Skipping',
577
                                $mapping->ID
578
                            )
579
                        );
580
                        continue;
581
                    }
582
583
                    // the user is a direct member of group with a mapping, add them to the SS group.
584 View Code Duplication
                    if ($mapping->DN == $groupDN) {
585
                        $group = $mapping->Group();
586
                        if ($group && $group->exists()) {
587
                            $group->Members()->add($member, [
588
                                'IsImportedFromLDAP' => '1'
589
                            ]);
590
                            $mappedGroupIDs[] = $mapping->GroupID;
591
                        }
592
                    }
593
594
                    // the user *might* be a member of a nested group provided the scope of the mapping
595
                    // is to include the entire subtree. Check all those mappings and find the LDAP child groups
596
                    // to see if they are a member of one of those. If they are, add them to the SS group
597
                    if ($mapping->Scope == 'Subtree') {
598
                        $childGroups = $this->getNestedGroups($mapping->DN, ['dn']);
599
                        if (!$childGroups) {
600
                            continue;
601
                        }
602
603
                        foreach ($childGroups as $childGroupDN => $childGroupRecord) {
604 View Code Duplication
                            if ($childGroupDN == $groupDN) {
605
                                $group = $mapping->Group();
606
                                if ($group && $group->exists()) {
607
                                    $group->Members()->add($member, [
608
                                        'IsImportedFromLDAP' => '1'
609
                                    ]);
610
                                    $mappedGroupIDs[] = $mapping->GroupID;
611
                                }
612
                            }
613
                        }
614
                    }
615
                }
616
            }
617
        }
618
619
        // remove the user from any previously mapped groups, where the mapping has since been removed
620
        $groupRecords = DB::query(
621
            sprintf(
622
                'SELECT "GroupID" FROM "Group_Members" WHERE "IsImportedFromLDAP" = 1 AND "MemberID" = %s',
623
                $member->ID
624
            )
625
        );
626
627
        foreach ($groupRecords as $groupRecord) {
628
            if (!in_array($groupRecord['GroupID'], $mappedGroupIDs)) {
629
                $group = Group::get()->byId($groupRecord['GroupID']);
630
                // Some groups may no longer exist. SilverStripe does not clean up join tables.
631
                if ($group) {
632
                    $group->Members()->remove($member);
633
                }
634
            }
635
        }
636
        // This will throw an exception if there are two distinct GUIDs with the same email address.
637
        // We are happy with a raw 500 here at this stage.
638
        $member->write();
639
    }
640
641
    /**
642
     * Sync a specific Group by updating it with LDAP data.
643
     *
644
     * @param Group $group An existing Group or a new Group object
645
     * @param array $data LDAP group object data
646
     *
647
     * @return bool
648
     */
649
    public function updateGroupFromLDAP(Group $group, $data)
650
    {
651
        if (!$this->enabled()) {
652
            return false;
653
        }
654
655
        // Synchronise specific guaranteed fields.
656
        $group->Code = $data['samaccountname'];
657
        if (!empty($data['name'])) {
658
            $group->Title = $data['name'];
659
        } else {
660
            $group->Title = $data['samaccountname'];
661
        }
662
        if (!empty($data['description'])) {
663
            $group->Description = $data['description'];
664
        }
665
        $group->DN = $data['dn'];
666
        $group->LastSynced = (string)DBDatetime::now();
667
        $group->write();
668
669
        // Mappings on this group are automatically maintained to contain just the group's DN.
670
        // First, scan through existing mappings and remove ones that are not matching (in case the group moved).
671
        $hasCorrectMapping = false;
672
        foreach ($group->LDAPGroupMappings() as $mapping) {
673
            if ($mapping->DN === $data['dn']) {
674
                // This is the correct mapping we want to retain.
675
                $hasCorrectMapping = true;
676
            } else {
677
                $mapping->delete();
678
            }
679
        }
680
681
        // Second, if the main mapping was not found, add it in.
682
        if (!$hasCorrectMapping) {
683
            $mapping = new LDAPGroupMapping();
684
            $mapping->DN = $data['dn'];
685
            $mapping->write();
686
            $group->LDAPGroupMappings()->add($mapping);
687
        }
688
    }
689
690
    /**
691
     * Creates a new LDAP user from the passed Member record.
692
     * Note that the Member record must have a non-empty Username field for this to work.
693
     *
694
     * @param Member $member
695
     * @throws ValidationException
696
     * @throws Exception
697
     */
698
    public function createLDAPUser(Member $member)
699
    {
700
        if (!$this->enabled()) {
701
            return;
702
        }
703
        if (empty($member->Username)) {
704
            throw new ValidationException('Member missing Username. Cannot create LDAP user');
705
        }
706
        if (!$this->config()->new_users_dn) {
707
            throw new Exception('LDAPService::new_users_dn must be configured to create LDAP users');
708
        }
709
710
        // Normalise username to lowercase to ensure we don't have duplicates of different cases
711
        $member->Username = strtolower($member->Username);
712
713
        // Create user in LDAP using available information.
714
        $dn = sprintf('CN=%s,%s', $member->Username, $this->config()->new_users_dn);
715
716
        try {
717
            $this->add($dn, [
718
                'objectclass' => 'user',
719
                'cn' => $member->Username,
720
                'accountexpires' => '9223372036854775807',
721
                'useraccountcontrol' => '66048',
722
                'userprincipalname' => sprintf(
723
                    '%s@%s',
724
                    $member->Username,
725
                    $this->gateway->config()->options['accountDomainName']
726
                ),
727
            ]);
728
        } catch (Exception $e) {
729
            throw new ValidationException('LDAP synchronisation failure: ' . $e->getMessage());
730
        }
731
732
        $user = $this->getUserByUsername($member->Username);
733
        if (empty($user['objectguid'])) {
734
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
735
        }
736
737
        // Creation was successful, mark the user as LDAP managed by setting the GUID.
738
        $member->GUID = $user['objectguid'];
739
    }
740
741
    /**
742
     * Creates a new LDAP group from the passed Group record.
743
     *
744
     * @param Group $group
745
     * @throws ValidationException
746
     */
747
    public function createLDAPGroup(Group $group) {
748
        if (!$this->enabled()) {
749
            return;
750
        }
751
        if (empty($group->Title)) {
752
            throw new ValidationException('Group missing Title. Cannot create LDAP group');
753
        }
754
        if (!$this->config()->new_groups_dn) {
755
            throw new Exception('LDAPService::new_groups_dn must be configured to create LDAP groups');
756
        }
757
758
        $dn = sprintf('CN=%s,%s', $group->Title, $this->config()->new_groups_dn);
759
        try {
760
            $this->add($dn, [
761
                'objectclass' => 'group',
762
                'cn' => $group->Title,
763
                'name' => $group->Title,
764
                'samaccountname' => $group->Title,
765
                'distinguishedname' => $dn
766
            ]);
767
        } catch (\Exception $e) {
768
            throw new \ValidationException('LDAP group creation failure: ' . $e->getMessage());
769
        }
770
771
        $data = $this->getGroupByDN($dn);
772
        if (empty($data['objectguid'])) {
773
            throw new \ValidationException(
774
                new \ValidationResult(
775
                    false,
776
                    'LDAP group creation failure: group might have been created in LDAP. GUID is missing.'
777
                )
778
            );
779
        }
780
781
        // Creation was successful, mark the group as LDAP managed by setting the GUID.
782
        $group->GUID = $data['objectguid'];
783
        $group->DN = $data['dn'];
784
    }
785
786
    /**
787
     * Update the Member data back to the corresponding LDAP user object.
788
     *
789
     * @param Member $member
790
     * @throws ValidationException
791
     */
792
    public function updateLDAPFromMember(Member $member)
793
    {
794
        if (!$this->enabled()) {
795
            return;
796
        }
797
        if (!$member->GUID) {
798
            throw new ValidationException('Member missing GUID. Cannot update LDAP user');
799
        }
800
801
        $data = $this->getUserByGUID($member->GUID);
802
        if (empty($data['objectguid'])) {
803
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
804
        }
805
806
        if (empty($member->Username)) {
807
            throw new ValidationException('Member missing Username. Cannot update LDAP user');
808
        }
809
810
        $dn = $data['distinguishedname'];
811
812
        // Normalise username to lowercase to ensure we don't have duplicates of different cases
813
        $member->Username = strtolower($member->Username);
814
815
        try {
816
            // If the common name (cn) has changed, we need to ensure they've been moved
817
            // to the new DN, to avoid any clashes between user objects.
818
            if ($data['cn'] != $member->Username) {
819
                $newDn = sprintf('CN=%s,%s', $member->Username, preg_replace('/^CN=(.+?),/', '', $dn));
820
                $this->move($dn, $newDn);
821
                $dn = $newDn;
822
            }
823
        } catch (Exception $e) {
824
            throw new ValidationException('LDAP move failure: '.$e->getMessage());
825
        }
826
827
        try {
828
            $attributes = [
829
                'displayname' => sprintf('%s %s', $member->FirstName, $member->Surname),
830
                'name' => sprintf('%s %s', $member->FirstName, $member->Surname),
831
                'userprincipalname' => sprintf(
832
                    '%s@%s',
833
                    $member->Username,
834
                    $this->gateway->config()->options['accountDomainName']
835
                ),
836
            ];
837
            foreach ($member->config()->ldap_field_mappings as $attribute => $field) {
838
                $relationClass = $member->getRelationClass($field);
839
                if ($relationClass) {
840
                    // todo no support for writing back relations yet.
841
                } else {
842
                    $attributes[$attribute] = $member->$field;
843
                }
844
            }
845
846
            $this->update($dn, $attributes);
847
        } catch (Exception $e) {
848
            throw new ValidationException('LDAP synchronisation failure: '.$e->getMessage());
849
        }
850
    }
851
852
    /**
853
     * Ensure the user belongs to the correct groups in LDAP from their membership
854
     * to local LDAP mapped SilverStripe groups.
855
     *
856
     * This also removes them from LDAP groups if they've been taken out of one.
857
     * It will not affect group membership of non-mapped groups, so it will
858
     * not touch such internal AD groups like "Domain Users".
859
     *
860
     * @param Member $member
861
     * @throws ValidationException
862
     */
863
    public function updateLDAPGroupsForMember(Member $member)
864
    {
865
        if (!$this->enabled()) {
866
            return;
867
        }
868
        if (!$member->GUID) {
869
            throw new ValidationException('Member missing GUID. Cannot update LDAP user');
870
        }
871
872
        $addGroups = [];
873
        $removeGroups = [];
874
875
        $user = $this->getUserByGUID($member->GUID);
876
        if (empty($user['objectguid'])) {
877
            throw new ValidationException('LDAP update failure: user missing GUID');
878
        }
879
880
        // If a user belongs to a single group, this comes through as a string.
881
        // Normalise to a array so it's consistent.
882
        $existingGroups = !empty($user['memberof']) ? $user['memberof'] : [];
883
        if ($existingGroups && is_string($existingGroups)) {
884
            $existingGroups = [$existingGroups];
885
        }
886
887
        foreach ($member->Groups() as $group) {
888
            if (!$group->GUID) {
889
                continue;
890
            }
891
892
            // mark this group as something we need to ensure the user belongs to in LDAP.
893
            $addGroups[] = $group->DN;
894
        }
895
896
        // Which existing LDAP groups are not in the add groups? We'll check these groups to
897
        // see if the user should be removed from any of them.
898
        $remainingGroups = array_diff($existingGroups, $addGroups);
899
900
        foreach ($remainingGroups as $groupDn) {
901
            // We only want to be removing groups we have a local Group mapped to. Removing
902
            // membership for anything else would be bad!
903
            $group = Group::get()->filter('DN', $groupDn)->first();
904
            if (!$group || !$group->exists()) {
905
                continue;
906
            }
907
908
            // this group should be removed from the user's memberof attribute, as it's been removed.
909
            $removeGroups[] = $groupDn;
910
        }
911
912
        // go through the groups we want the user to be in and ensure they're in them.
913
        foreach ($addGroups as $groupDn) {
914
            $this->addLDAPUserToGroup($user['distinguishedname'], $groupDn);
915
        }
916
917
        // go through the groups we _don't_ want the user to be in and ensure they're taken out of them.
918
        foreach ($removeGroups as $groupDn) {
919
            $members = $this->getLDAPGroupMembers($groupDn);
920
921
            // remove the user from the members data.
922
            if (in_array($user['distinguishedname'], $members)) {
923
                foreach ($members as $i => $dn) {
924
                    if ($dn == $user['distinguishedname']) {
925
                        unset($members[$i]);
926
                    }
927
                }
928
            }
929
930
            try {
931
                $this->update($groupDn, ['member' => $members]);
932
            } catch (Exception $e) {
933
                throw new ValidationException('LDAP group membership remove failure: ' . $e->getMessage());
934
            }
935
        }
936
    }
937
938
    /**
939
     * Add LDAP user by DN to LDAP group.
940
     *
941
     * @param string $userDn
942
     * @param string $groupDn
943
     * @throws \Exception
944
     */
945
    public function addLDAPUserToGroup($userDn, $groupDn) {
946
        $members = $this->getLDAPGroupMembers($groupDn);
947
948
        // this user is already in the group, no need to do anything.
949
        if (in_array($userDn, $members)) {
950
            return;
951
        }
952
953
        $members[] = $userDn;
954
955
        try {
956
            $this->update($groupDn, ['member' => $members]);
957
        } catch (Exception $e) {
958
            throw new ValidationException('LDAP group membership add failure: ' . $e->getMessage());
959
        }
960
    }
961
962
    /**
963
     * Change a members password on the AD. Works with ActiveDirectory compatible services that saves the
964
     * password in the `unicodePwd` attribute.
965
     *
966
     * @todo Use the Zend\Ldap\Attribute::setPassword functionality to create a password in
967
     * an abstract way, so it works on other LDAP directories, not just Active Directory.
968
     *
969
     * Ensure that the LDAP bind:ed user can change passwords and that the connection is secure.
970
     *
971
     * @param Member $member
972
     * @param string $password
973
     * @param string|null $oldPassword Supply old password to perform a password change (as opposed to password reset)
974
     * @return ValidationResult
975
     */
976
    public function setPassword(Member $member, $password, $oldPassword = null)
977
    {
978
        $validationResult = ValidationResult::create();
979
980
        $this->extend('onBeforeSetPassword', $member, $password, $validationResult);
981
982
        if (!$member->GUID) {
983
            $this->getLogger()->warn(sprintf('Cannot update Member ID %s, GUID not set', $member->ID));
984
            $validationResult->addError(
985
                _t(
986
                    'LDAPAuthenticator.NOUSER',
987
                    'Your account hasn\'t been setup properly, please contact an administrator.'
988
                )
989
            );
990
            return $validationResult;
991
        }
992
993
        $userData = $this->getUserByGUID($member->GUID);
994
        if (empty($userData['distinguishedname'])) {
995
            $validationResult->addError(
996
                _t(
997
                    'LDAPAuthenticator.NOUSER',
998
                    'Your account hasn\'t been setup properly, please contact an administrator.'
999
                )
1000
            );
1001
            return $validationResult;
1002
        }
1003
1004
        try {
1005
            if (!empty($oldPassword)) {
1006
                $this->gateway->changePassword($userData['distinguishedname'], $password, $oldPassword);
1007
            } elseif ($this->config()->password_history_workaround) {
1008
                $this->passwordHistoryWorkaround($userData['distinguishedname'], $password);
1009
            } else {
1010
                $this->gateway->resetPassword($userData['distinguishedname'], $password);
1011
            }
1012
            $this->extend('onAfterSetPassword', $member, $password, $validationResult);
1013
        } catch (Exception $e) {
1014
            $validationResult->addError($e->getMessage());
1015
        }
1016
1017
        return $validationResult;
1018
    }
1019
1020
    /**
1021
     * Delete an LDAP user mapped to the Member record
1022
     * @param Member $member
1023
     * @throws ValidationException
1024
     */
1025
    public function deleteLDAPMember(Member $member)
1026
    {
1027
        if (!$this->enabled()) {
1028
            return;
1029
        }
1030
        if (!$member->GUID) {
1031
            throw new ValidationException('Member missing GUID. Cannot delete LDAP user');
1032
        }
1033
        $data = $this->getUserByGUID($member->GUID);
1034
        if (empty($data['distinguishedname'])) {
1035
            throw new ValidationException('LDAP delete failure: could not find distinguishedname attribute');
1036
        }
1037
1038
        try {
1039
            $this->delete($data['distinguishedname']);
1040
        } catch (Exception $e) {
1041
            throw new ValidationException('LDAP delete user failed: ' . $e->getMessage());
1042
        }
1043
    }
1044
1045
    /**
1046
     * A simple proxy to LDAP update operation.
1047
     *
1048
     * @param string $dn Location to add the entry at.
1049
     * @param array $attributes A simple associative array of attributes.
1050
     */
1051
    public function update($dn, array $attributes)
1052
    {
1053
        $this->gateway->update($dn, $attributes);
1054
    }
1055
1056
    /**
1057
     * A simple proxy to LDAP delete operation.
1058
     *
1059
     * @param string $dn Location of object to delete
1060
     * @param bool $recursively Recursively delete nested objects?
1061
     */
1062
    public function delete($dn, $recursively = false)
1063
    {
1064
        $this->gateway->delete($dn, $recursively);
1065
    }
1066
1067
    /**
1068
     * A simple proxy to LDAP copy/delete operation.
1069
     *
1070
     * @param string $fromDn
1071
     * @param string $toDn
1072
     * @param bool $recursively Recursively move nested objects?
1073
     */
1074
    public function move($fromDn, $toDn, $recursively = false)
1075
    {
1076
        $this->gateway->move($fromDn, $toDn, $recursively);
1077
    }
1078
1079
    /**
1080
     * A simple proxy to LDAP add operation.
1081
     *
1082
     * @param string $dn Location to add the entry at.
1083
     * @param array $attributes A simple associative array of attributes.
1084
     */
1085
    public function add($dn, array $attributes)
1086
    {
1087
        $this->gateway->add($dn, $attributes);
1088
    }
1089
1090
    /**
1091
     * @param string $dn Distinguished name of the user
1092
     * @param string $password New password.
1093
     * @throws Exception
1094
     */
1095
    private function passwordHistoryWorkaround($dn, $password)
1096
    {
1097
        $generator = new RandomGenerator();
1098
        // 'Aa1' is there to satisfy the complexity criterion.
1099
        $tempPassword = sprintf('Aa1%s', substr($generator->randomToken('sha1'), 0, 21));
1100
        $this->gateway->resetPassword($dn, $tempPassword);
1101
        $this->gateway->changePassword($dn, $password, $tempPassword);
1102
    }
1103
1104
    /**
1105
     * Get a logger
1106
     *
1107
     * @return LoggerInterface
1108
     */
1109
    public function getLogger()
1110
    {
1111
        return Injector::inst()->get('Logger');
1112
    }
1113
}
1114