Completed
Pull Request — master (#64)
by Sean
06:12 queued 03:28
created

LDAPMemberExtension::getLDAPGroupMembers()   B

Complexity

Conditions 5
Paths 5

Size

Total Lines 18
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 18
rs 8.8571
cc 5
eloc 10
nc 5
nop 1
1
<?php
2
/**
3
 * Class LDAPMemberExtension.
4
 *
5
 * Adds mappings from AD attributes to SilverStripe {@link Member} fields.
6
 */
7
class LDAPMemberExtension extends DataExtension
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class must be in a namespace of at least one level to avoid collisions.

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

namespace YourVendor;

class YourClass { }

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

Loading history...
8
{
9
    /**
10
     * @var array
11
     */
12
    private static $db = array(
0 ignored issues
show
Unused Code introduced by
The property $db 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...
13
        // Unique user identifier, same field is used by SAMLMemberExtension
14
        'GUID' => 'Varchar(50)',
15
        'Username' => 'Varchar(64)',
16
        'IsImportedFromLDAP' => 'Boolean',
17
        'IsExpired' => 'Boolean',
18
        'LastSynced' => 'SS_Datetime',
19
    );
20
21
    /**
22
     * These fields are used by {@link LDAPMemberSync} to map specific AD attributes
23
     * to {@link Member} fields.
24
     *
25
     * @var array
26
     * @config
27
     */
28
    private static $ldap_field_mappings = array(
0 ignored issues
show
Unused Code introduced by
The property $ldap_field_mappings 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...
29
        'samaccountname' => 'Username',
30
        'givenname' => 'FirstName',
31
        'sn' => 'Surname',
32
        'mail' => 'Email',
33
    );
34
35
    /**
36
     * The location (relative to /assets) where to save thumbnailphoto data.
37
     *
38
     * @var string
39
     * @config
40
     */
41
    private static $ldap_thumbnail_path = 'Uploads';
0 ignored issues
show
Unused Code introduced by
The property $ldap_thumbnail_path 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...
42
43
    /**
44
     * When enabled, LDAP managed users have their data written back to LDAP
45
     * This is a push to LDAP, rather than {@link LDAPMemberSyncTask}
46
     * which pulls from LDAP instead.
47
     *
48
     * This requires setting write permissions on the user who talks to LDAP,
49
     * which is why it's disabled by default.
50
     *
51
     * Note that some constants must be configured in your environment file
52
     * for this to work:
53
     *
54
     * LDAP_DOMAIN - the base DN of the directory. e.g. "DN=mydomain,DC=com"
55
     * LDAP_NEW_USERS_OBJECT_CATEGORY - the type of object. e.g. "CN=Person,CN=Schema,DC=mydomain,DC=com"
56
     * LDAP_NEW_USERS_DN - where to place users in the directory. e.g. "OU=Users,DC=mydomain,DC=com"
57
     *
58
     * @var bool
59
     * @config
60
     */
61
    private static $reverse_sync_ldap = false;
0 ignored issues
show
Unused Code introduced by
The property $reverse_sync_ldap is not used and could be removed.

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

Loading history...
62
63
    /**
64
     * If enabled, new users written are also created in LDAP.
65
     *
66
     * This requires setting write permissions on the user who talks to LDAP,
67
     * which is why it's disabled by default.
68
     *
69
     * Please see {@link $reverse_sync_ldap} for constants that must be configured in
70
     * your environment file for this to work.
71
     *
72
     * @var bool
73
     * @config
74
     */
75
    private static $create_new_users_in_ldap = false;
0 ignored issues
show
Unused Code introduced by
The property $create_new_users_in_ldap 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...
76
77
    /**
78
     * @var array
79
     */
80
    private static $dependencies = array(
0 ignored issues
show
Unused Code introduced by
The property $dependencies is not used and could be removed.

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

Loading history...
81
        'ldapService' => '%$LDAPService',
82
    );
83
84
    public function updateSummaryFields(&$fields)
85
    {
86
        $fields = ['Username' => 'Username'] + $fields;
87
    }
88
89
    /**
90
     * @param FieldList $fields
91
     */
92
    public function updateCMSFields(FieldList $fields)
93
    {
94
        // Redo LDAP metadata fields as read-only and move to LDAP tab.
95
        $ldapMetadata = array();
96
        $fields->replaceField('IsImportedFromLDAP', $ldapMetadata[] = new ReadonlyField(
97
            'IsImportedFromLDAP',
98
            _t('LDAPMemberExtension.ISIMPORTEDFROMLDAP', 'Is user imported from LDAP/AD?')
99
        ));
100
        $fields->replaceField('GUID', $ldapMetadata[] = new ReadonlyField('GUID'));
101
        $fields->replaceField('IsExpired', $ldapMetadata[] = new ReadonlyField(
102
            'IsExpired',
103
            _t('LDAPMemberExtension.ISEXPIRED', 'Has user\'s LDAP/AD login expired?'))
104
        );
105
        $fields->replaceField('LastSynced', $ldapMetadata[] = new ReadonlyField(
106
            'LastSynced',
107
            _t('LDAPMemberExtension.LASTSYNCED', 'Last synced'))
108
        );
109
        $fields->addFieldsToTab('Root.LDAP', $ldapMetadata);
110
111
        if ($this->owner->IsImportedFromLDAP) {
112
            // Transform the automatically mapped fields into read-only.
113
            foreach ($this->owner->config()->ldap_field_mappings as $name) {
114
                $field = $fields->dataFieldByName($name);
115
                if (!empty($field)) {
116
                    // Set to readonly, but not disabled so that the data is still sent to the
117
                    // server and doesn't break Member_Validator
118
                    $field->setReadonly(true);
119
                    $field->setTitle($field->Title()._t('LDAPMemberExtension.IMPORTEDFIELD', ' (imported)'));
120
                }
121
            }
122
123
            // Display alert message at the top.
124
            $message = _t(
125
                'LDAPMemberExtension.INFOIMPORTED',
126
                'This user is automatically imported from LDAP. '.
127
                    'Manual changes to imported fields will be removed upon sync.'
128
            );
129
            $fields->addFieldToTab(
130
                'Root.Main',
131
                new LiteralField(
132
                    'Info',
133
                    sprintf('<p class="message warning">%s</p>', $message)
134
                ),
135
                'FirstName'
136
            );
137
        }
138
    }
139
140
    /**
141
     * Creates a new LDAP user given the current Member details. Assumption is
142
     * the record has been validated for the presence of FirstName, Surname, Email,
143
     * and Username prior to the request being sent to LDAP.
144
     */
145
    public function createUser()
146
    {
147
        $service = $this->ldapService;
0 ignored issues
show
Bug introduced by
The property ldapService does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
148
        if (!$service->enabled()) {
149
            return;
150
        }
151
152
        // Create user in LDAP using available information.
153
        $dn = sprintf('CN=%s,%s', $this->owner->Username, LDAP_NEW_USERS_DN);
154
155
        try {
156
            $service->add($dn, [
157
                'objectClass' => 'user',
158
                'cn' => $this->owner->Username,
159
                'sn' => $this->owner->Surname,
160
                'givenName' => $this->owner->FirstName,
161
                'instanceType' => '4',
162
                'displayName' => sprintf('%s %s', $this->owner->FirstName, $this->owner->Surname),
163
                'name' => sprintf('%s %s', $this->owner->FirstName, $this->owner->Surname),
164
                'codePage' => '0',
165
                'countryCode' => '0',
166
                'accountExpires' => '9223372036854775807',
167
                'sAMAccountName' => $this->owner->Username,
168
                'userPrincipalName' => sprintf('%s@%s', $this->owner->Username, LDAP_DOMAIN),
169
                'objectCategory' => LDAP_NEW_USERS_OBJECT_CATEGORY,
170
                'userAccountControl' => '66048',
171
                'mail' => $this->owner->Email,
172
                'distinguishedName' => $dn,
173
            ]);
174
        } catch (\Exception $e) {
175
            throw new ValidationException('LDAP synchronisation failure: '.$e->getMessage());
176
        }
177
178
        $user = $service->getUserByUsername($this->owner->Username);
179
        if (empty($user['objectguid'])) {
180
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
181
        }
182
183
        // Creation was successful, mark the user as synced with LDAP.
184
        $this->owner->GUID = $user['objectguid'];
185
        $this->owner->IsImportedFromLDAP = 1;
186
    }
187
188
    /**
189
     * Sync the Member data back to the corresponding LDAP user object.
190
     *
191
     * This is effectively a reverse sync, so we don't want to be doing
192
     * this onBeforeWrite as LDAPMemberSyncTask could get it into a loop.
193
     * This method should be called explicitly when a sync of the
194
     * Platform Dashboard user back to LDAP is required.
195
     *
196
     * @throws ValidationException
197
     */
198
    public function sync()
199
    {
200
        $service = $this->ldapService;
201
        if (!$service->enabled()) {
202
            return;
203
        }
204
        if (!$this->owner->IsImportedFromLDAP) {
205
            return;
206
        }
207
208
        $data = $service->getUserByGUID($this->owner->GUID);
209
        if (empty($data['objectguid'])) {
210
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
211
        }
212
213
        $dn = $data['distinguishedname'];
214
215
        try {
216
            // If the common name (cn) has changed, we need to ensure they've been moved
217
            // to the new DN, to avoid any clashes between user objects.
218
            if ($data['cn'] != $this->owner->Username) {
219
                $newDn = sprintf('CN=%s,%s', $this->owner->Username, preg_replace('/^CN=(.+?),/', '', $dn));
220
                $service->move($dn, $newDn);
221
                $dn = $newDn;
222
            }
223
224
            $service->update($dn, [
225
                'sn' => $this->owner->Surname,
226
                'givenName' => $this->owner->FirstName,
227
                'displayName' => sprintf('%s %s', $this->owner->FirstName, $this->owner->Surname),
228
                'name' => sprintf('%s %s', $this->owner->FirstName, $this->owner->Surname),
229
                'sAMAccountName' => $this->owner->Username,
230
                'userPrincipalName' => sprintf('%s@%s', $this->owner->Username, LDAP_DOMAIN),
231
                'mail' => $this->owner->Email,
232
            ]);
233
        } catch (\Exception $e) {
234
            throw new ValidationException('LDAP synchronisation failure: '.$e->getMessage());
235
        }
236
    }
237
238
    public function validate(ValidationResult $validationResult)
239
    {
240
        if (!$this->owner->config()->create_new_users_in_ldap) {
241
            return;
242
        }
243
244
        // We allow empty Username for registration purposes, as we need to
245
        // create Member records with empty Username temporarily. Forms should explicitly
246
        // check for Username not being empty if they require it not to be.
247
        if (empty($this->owner->Username)) {
248
            return;
249
        }
250
251
        if (!preg_match('/^[a-z0-9\.]+$/', $this->owner->Username)) {
252
            $validationResult->error(
253
                'Username must only contain lowercase alphanumeric characters and dots.',
254
                'bad'
255
            );
256
            throw new ValidationException($validationResult);
257
        }
258
    }
259
260
    /**
261
     * Ensure the user belongs to the correct groups in LDAP, making the
262
     * assumption that the assigned groups are correct.
263
     * This is considered a reverse sync back to LDAP.
264
     *
265
     * This also removes them from LDAP groups if they've been taken out of one.
266
     * It will not affect group membership of non-mapped groups, so it will
267
     * not touch such internal AD groups like "Domain Users".
268
     */
269
    public function syncGroups()
270
    {
271
        $service = $this->ldapService;
272
        if (!$service->enabled()) {
273
            return;
274
        }
275
        if (!$this->owner->IsImportedFromLDAP) {
276
            return;
277
        }
278
279
        $addGroups = array();
280
        $removeGroups = array();
281
282
        $user = $service->getUserByGUID($this->owner->GUID);
283
        if (empty($user['objectguid'])) {
284
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
285
        }
286
287
        // If a user belongs to a single group, this comes through as a string.
288
        // Normalise to a array so it's consistent.
289
        $existingGroups = !empty($user['memberof']) ? $user['memberof'] : array();
290
        if ($existingGroups && is_string($existingGroups)) {
291
            $existingGroups = array($existingGroups);
292
        }
293
294
        foreach ($this->owner->Groups() as $group) {
295
            if (!$group->IsImportedFromLDAP) {
296
                continue;
297
            }
298
299
            // mark this group as something we need to ensure the user belongs to in LDAP.
300
            $addGroups[] = $group->DN;
301
        }
302
303
        // Which existing LDAP groups are not in the add groups? We'll check these groups to
304
        // see if the user should be removed from any of them.
305
        $remainingGroups = array_diff($existingGroups, $addGroups);
306
307
        foreach ($remainingGroups as $groupDn) {
308
            // We only want to be removing groups we have a local Group mapped to. Removing
309
            // membership for anything else would be bad!
310
            $group = Group::get()->filter('DN', $groupDn)->first();
311
            if (!$group || !$group->exists()) {
312
                continue;
313
            }
314
315
            // this group should be removed from the user's memberof attribute, as it's been removed.
316
            $removeGroups[] = $groupDn;
317
        }
318
319
        // go through the groups we want the user to be in and ensure they're in them.
320
        foreach ($addGroups as $groupDn) {
321
            $members = $this->getLDAPGroupMembers($groupDn);
322
323
            // this user is already in the group, no need to do anything.
324
            if (in_array($user['distinguishedname'], $members)) {
325
                continue;
326
            }
327
328
            $members[] = $user['distinguishedname'];
329
330
            try {
331
                $service->update($groupDn, array('member' => $members));
332
            } catch (\Exception $e) {
333
                throw new ValidationException('LDAP group membership add failure: '.$e->getMessage());
334
            }
335
        }
336
337
        // go through the groups we _don't_ want the user to be in and ensure they're taken out of them.
338
        foreach ($removeGroups as $groupDn) {
339
            $members = $this->getLDAPGroupMembers($groupDn);
340
341
            // remove the user from the members data.
342
            if (in_array($user['distinguishedname'], $members)) {
343
                foreach ($members as $i => $dn) {
344
                    if ($dn == $user['distinguishedname']) {
345
                        unset($members[$i]);
346
                    }
347
                }
348
            }
349
350
            try {
351
                $service->update($groupDn, array('member' => $members));
352
            } catch (\Exception $e) {
353
                throw new ValidationException('LDAP group membership remove failure: '.$e->getMessage());
354
            }
355
        }
356
    }
357
358
    /**
359
     * Given a group DN, look up the group membership data in LDAP.
360
     *
361
     * @param string $groupDn
362
     *
363
     * @return array
364
     */
365
    protected function getLDAPGroupMembers($groupDn)
366
    {
367
        $service = $this->ldapService;
368
        if (!$service->enabled()) {
369
            return;
370
        }
371
372
        $groupObj = Group::get()->filter('DN', $groupDn)->first();
373
        $groupData = $service->getGroupByGUID($groupObj->GUID);
374
        $members = !empty($groupData['member']) ? $groupData['member'] : array();
375
        // If a user belongs to a single group, this comes through as a string.
376
        // Normalise to a array so it's consistent.
377
        if ($members && is_string($members)) {
378
            $members = array($members);
379
        }
380
381
        return $members;
382
    }
383
384
    /**
385
     * Create the user in LDAP and mark as synced, provided that
386
     * reverse sync is enabled.
387
     *
388
     * Set a flag "Creating" so other extensions using on*() events can
389
     * detect whether it's in a state of being created, such as for
390
     * synchronising with other services when a user is being created
391
     * in LDAP for the first time.
392
     */
393
    public function onBeforeWrite()
394
    {
395
        if (!$this->owner->config()->create_new_users_in_ldap) {
396
            return;
397
        }
398
        $service = $this->ldapService;
399
        if (!$service->enabled()) {
400
            return;
401
        }
402
403
        if ($this->owner->Username) {
404
            $this->owner->Username = strtolower($this->owner->Username);
405
        }
406
407
        // Ensure the user exists LDAP.
408
        if ($this->owner->Username && !$this->owner->IsImportedFromLDAP) {
409
            $this->createUser();
410
            $this->owner->Creating = true;
411
        }
412
    }
413
414
    /**
415
     * Sync the local data with LDAP, and ensure local membership is also set in
416
     * LDAP too. This writes into LDAP, provided reverse sync is enabled.
417
     */
418
    public function onAfterWrite()
419
    {
420
        if (!$this->owner->config()->reverse_sync_ldap) {
421
            return;
422
        }
423
        if ($this->owner->IsImportedFromLDAP) {
424
            $this->sync();
425
            $this->syncGroups();
426
        }
427
428
        if (
429
            !$this->owner->Creating ||
430
            !$this->owner->IsImportedFromLDAP
431
        ) {
432
            return;
433
        }
434
435
        // muzdowski: Creation is entering the last phase. I have seen framework trying to ->write
436
        // twice in a row, resulting in an irrelevant error second time around. Mark creation
437
        // explicitly as done to prevent that.
438
        $this->owner->Creating = false;
439
    }
440
441
    /**
442
     * Triggered by {@link Member::logIn()} when successfully logged in,
443
     * this will update the Member record from AD data.
444
     */
445
    public function memberLoggedIn()
446
    {
447
        if ($this->owner->GUID) {
448
            $this->ldapService->updateMemberFromLDAP($this->owner);
449
        }
450
    }
451
}
452