Completed
Pull Request — master (#64)
by Sean
02:15
created

LDAPMemberExtension::sync()   C

Complexity

Conditions 8
Paths 24

Size

Total Lines 45
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 45
rs 5.3846
cc 8
eloc 27
nc 24
nop 0
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
        'sn' => 'Surname',
31
        'mail' => 'Email',
32
    );
33
34
    /**
35
     * The location (relative to /assets) where to save thumbnailphoto data.
36
     *
37
     * @var string
38
     * @config
39
     */
40
    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...
41
42
    /**
43
     * When enabled, LDAP managed users have their data written back to LDAP.
44
     * This is a push to LDAP, rather than {@link LDAPMemberSyncTask}
45
     * which pulls from LDAP instead.
46
     *
47
     * This requires setting write permissions on the user who talks to LDAP,
48
     * which is why it's disabled by default.
49
     *
50
     * Note that some constants must be configured in your environment file
51
     * for this to work:
52
     *
53
     * LDAP_DOMAIN - the base DN of the directory. e.g. "DN=mydomain,DC=com"
54
     * LDAP_NEW_USERS_OBJECT_CATEGORY - the type of object. e.g. "CN=Person,CN=Schema,DC=mydomain,DC=com"
55
     * LDAP_NEW_USERS_DN - where to place users in the directory. e.g. "OU=Users,DC=mydomain,DC=com"
56
     *
57
     * @var bool
58
     * @config
59
     */
60
    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...
61
62
    /**
63
     * If enabled, new users written are also created in LDAP.
64
     *
65
     * This requires setting write permissions on the user who talks to LDAP,
66
     * which is why it's disabled by default.
67
     *
68
     * Please see {@link $reverse_sync_ldap} for constants that must be configured in
69
     * your environment file for this to work.
70
     *
71
     * @var bool
72
     * @config
73
     */
74
    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...
75
76
    /**
77
     * @var array
78
     */
79
    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...
80
        'ldapService' => '%$LDAPService',
81
    );
82
83
    public function updateSummaryFields(&$fields)
84
    {
85
        $fields = array('Username' => 'Username') + $fields;
86
    }
87
88
    /**
89
     * @param FieldList $fields
90
     */
91
    public function updateCMSFields(FieldList $fields)
92
    {
93
        // Redo LDAP metadata fields as read-only and move to LDAP tab.
94
        $ldapMetadata = array();
95
        $fields->replaceField('IsImportedFromLDAP', $ldapMetadata[] = new ReadonlyField(
96
            'IsImportedFromLDAP',
97
            _t('LDAPMemberExtension.ISIMPORTEDFROMLDAP', 'Is user imported from LDAP/AD?')
98
        ));
99
        $fields->replaceField('GUID', $ldapMetadata[] = new ReadonlyField('GUID'));
100
        $fields->replaceField('IsExpired', $ldapMetadata[] = new ReadonlyField(
101
            'IsExpired',
102
            _t('LDAPMemberExtension.ISEXPIRED', 'Has user\'s LDAP/AD login expired?'))
103
        );
104
        $fields->replaceField('LastSynced', $ldapMetadata[] = new ReadonlyField(
105
            'LastSynced',
106
            _t('LDAPMemberExtension.LASTSYNCED', 'Last synced'))
107
        );
108
        $fields->addFieldsToTab('Root.LDAP', $ldapMetadata);
109
110
        if ($this->owner->IsImportedFromLDAP && !$this->config()->reverse_sync_ldap) {
0 ignored issues
show
Bug introduced by
The method config() does not exist on LDAPMemberExtension. Did you maybe mean get_extra_config()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
111
            // Transform the automatically mapped fields into read-only. This doesn't
112
            // apply if reverse sync is enabled, as changing data locally can be written back.
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. This is not applicable if reverse sync is
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.
142
     */
143
    public function createUser()
144
    {
145
        $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...
146
        if (!$service->enabled()) {
147
            return;
148
        }
149
150
        // Create user in LDAP using available information.
151
        $dn = sprintf('CN=%s,%s', $this->owner->Username, LDAP_NEW_USERS_DN);
152
153
        try {
154
            $service->add($dn, [
155
                'objectclass' => 'user',
156
                'cn' => $this->owner->Username,
157
                'instancetype' => '4',
158
                'codepage' => '0',
159
                'countrycode' => '0',
160
                'accountexpires' => '9223372036854775807',
161
                'userprincipalname' => sprintf('%s@%s', $this->owner->Username, LDAP_DOMAIN),
162
                'objectcategory' => LDAP_NEW_USERS_OBJECT_CATEGORY,
163
                'useraccountcontrol' => '66048',
164
                'distinguishedname' => $dn,
165
            ]);
166
        } catch (\Exception $e) {
167
            throw new ValidationException('LDAP synchronisation failure: '.$e->getMessage());
168
        }
169
170
        $user = $service->getUserByUsername($this->owner->Username);
171
        if (empty($user['objectguid'])) {
172
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
173
        }
174
175
        // Creation was successful, mark the user as synced with LDAP.
176
        $this->owner->GUID = $user['objectguid'];
177
        $this->owner->IsImportedFromLDAP = 1;
178
    }
179
180
    /**
181
     * Sync the Member data back to the corresponding LDAP user object.
182
     * @throws ValidationException
183
     */
184
    public function sync()
185
    {
186
        $service = $this->ldapService;
187
        if (!$service->enabled()) {
188
            return;
189
        }
190
        if (!$this->owner->IsImportedFromLDAP) {
191
            return;
192
        }
193
194
        $data = $service->getUserByGUID($this->owner->GUID);
195
        if (empty($data['objectguid'])) {
196
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
197
        }
198
199
        $dn = $data['distinguishedname'];
200
201
        try {
202
            // If the common name (cn) has changed, we need to ensure they've been moved
203
            // to the new DN, to avoid any clashes between user objects.
204
            if ($data['cn'] != $this->owner->Username) {
205
                $newDn = sprintf('CN=%s,%s', $this->owner->Username, preg_replace('/^CN=(.+?),/', '', $dn));
206
                $service->move($dn, $newDn);
207
                $dn = $newDn;
208
            }
209
210
            $attributes = array(
211
                'displayname' => sprintf('%s %s', $this->owner->FirstName, $this->owner->Surname),
212
                'name' => sprintf('%s %s', $this->owner->FirstName, $this->owner->Surname),
213
                'userprincipalname' => sprintf('%s@%s', $this->owner->Username, LDAP_DOMAIN),
214
            );
215
            foreach ($this->owner->config()->ldap_field_mappings as $attribute => $field) {
216
                $relationClass = $this->owner->getRelationClass($field);
217
                if ($relationClass) {
0 ignored issues
show
Unused Code introduced by
This if statement is empty and can be removed.

This check looks for the bodies of if statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These if bodies can be removed. If you have an empty if but statements in the else branch, consider inverting the condition.

if (rand(1, 6) > 3) {
//print "Check failed";
} else {
    print "Check succeeded";
}

could be turned into

if (rand(1, 6) <= 3) {
    print "Check succeeded";
}

This is much more concise to read.

Loading history...
218
                    // todo no support for writing back relations yet.
219
                } else {
220
                    $attributes[$attribute] = $this->owner->$field;
221
                }
222
            }
223
224
            $service->update($dn, $attributes);
225
        } catch (\Exception $e) {
226
            throw new ValidationException('LDAP synchronisation failure: '.$e->getMessage());
227
        }
228
    }
229
230
    public function validate(ValidationResult $validationResult)
231
    {
232
        if (!$this->owner->config()->create_new_users_in_ldap) {
233
            return;
234
        }
235
236
        // We allow empty Username for registration purposes, as we need to
237
        // create Member records with empty Username temporarily. Forms should explicitly
238
        // check for Username not being empty if they require it not to be.
239
        if (empty($this->owner->Username)) {
240
            return;
241
        }
242
243
        if (!preg_match('/^[a-z0-9\.]+$/', $this->owner->Username)) {
244
            $validationResult->error(
245
                'Username must only contain lowercase alphanumeric characters and dots.',
246
                'bad'
247
            );
248
            throw new ValidationException($validationResult);
249
        }
250
    }
251
252
    /**
253
     * Ensure the user belongs to the correct groups in LDAP, making the
254
     * assumption that the assigned groups are correct.
255
     * This is considered a reverse sync back to LDAP.
256
     *
257
     * This also removes them from LDAP groups if they've been taken out of one.
258
     * It will not affect group membership of non-mapped groups, so it will
259
     * not touch such internal AD groups like "Domain Users".
260
     */
261
    public function syncGroups()
262
    {
263
        $service = $this->ldapService;
264
        if (!$service->enabled()) {
265
            return;
266
        }
267
        if (!$this->owner->IsImportedFromLDAP) {
268
            return;
269
        }
270
271
        $addGroups = array();
272
        $removeGroups = array();
273
274
        $user = $service->getUserByGUID($this->owner->GUID);
275
        if (empty($user['objectguid'])) {
276
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
277
        }
278
279
        // If a user belongs to a single group, this comes through as a string.
280
        // Normalise to a array so it's consistent.
281
        $existingGroups = !empty($user['memberof']) ? $user['memberof'] : array();
282
        if ($existingGroups && is_string($existingGroups)) {
283
            $existingGroups = array($existingGroups);
284
        }
285
286
        foreach ($this->owner->Groups() as $group) {
287
            if (!$group->IsImportedFromLDAP) {
288
                continue;
289
            }
290
291
            // mark this group as something we need to ensure the user belongs to in LDAP.
292
            $addGroups[] = $group->DN;
293
        }
294
295
        // Which existing LDAP groups are not in the add groups? We'll check these groups to
296
        // see if the user should be removed from any of them.
297
        $remainingGroups = array_diff($existingGroups, $addGroups);
298
299
        foreach ($remainingGroups as $groupDn) {
300
            // We only want to be removing groups we have a local Group mapped to. Removing
301
            // membership for anything else would be bad!
302
            $group = Group::get()->filter('DN', $groupDn)->first();
303
            if (!$group || !$group->exists()) {
304
                continue;
305
            }
306
307
            // this group should be removed from the user's memberof attribute, as it's been removed.
308
            $removeGroups[] = $groupDn;
309
        }
310
311
        // go through the groups we want the user to be in and ensure they're in them.
312
        foreach ($addGroups as $groupDn) {
313
            $members = $this->getLDAPGroupMembers($groupDn);
314
315
            // this user is already in the group, no need to do anything.
316
            if (in_array($user['distinguishedname'], $members)) {
317
                continue;
318
            }
319
320
            $members[] = $user['distinguishedname'];
321
322
            try {
323
                $service->update($groupDn, array('member' => $members));
324
            } catch (\Exception $e) {
325
                throw new ValidationException('LDAP group membership add failure: '.$e->getMessage());
326
            }
327
        }
328
329
        // go through the groups we _don't_ want the user to be in and ensure they're taken out of them.
330
        foreach ($removeGroups as $groupDn) {
331
            $members = $this->getLDAPGroupMembers($groupDn);
332
333
            // remove the user from the members data.
334
            if (in_array($user['distinguishedname'], $members)) {
335
                foreach ($members as $i => $dn) {
336
                    if ($dn == $user['distinguishedname']) {
337
                        unset($members[$i]);
338
                    }
339
                }
340
            }
341
342
            try {
343
                $service->update($groupDn, array('member' => $members));
344
            } catch (\Exception $e) {
345
                throw new ValidationException('LDAP group membership remove failure: '.$e->getMessage());
346
            }
347
        }
348
    }
349
350
    /**
351
     * Given a group DN, look up the group membership data in LDAP.
352
     *
353
     * @param string $groupDn
354
     *
355
     * @return array
356
     */
357
    protected function getLDAPGroupMembers($groupDn)
358
    {
359
        $service = $this->ldapService;
360
        if (!$service->enabled()) {
361
            return;
362
        }
363
364
        $groupObj = Group::get()->filter('DN', $groupDn)->first();
365
        $groupData = $service->getGroupByGUID($groupObj->GUID);
366
        $members = !empty($groupData['member']) ? $groupData['member'] : array();
367
        // If a user belongs to a single group, this comes through as a string.
368
        // Normalise to a array so it's consistent.
369
        if ($members && is_string($members)) {
370
            $members = array($members);
371
        }
372
373
        return $members;
374
    }
375
376
    /**
377
     * Create the user in LDAP and mark as synced, provided that
378
     * reverse sync is enabled.
379
     *
380
     * Set a flag "Creating" so other extensions using on*() events can
381
     * detect whether it's in a state of being created, such as for
382
     * synchronising with other services when a user is being created
383
     * in LDAP for the first time.
384
     */
385
    public function onBeforeWrite()
386
    {
387
        if (!$this->owner->config()->create_new_users_in_ldap) {
388
            return;
389
        }
390
        $service = $this->ldapService;
391
        if (!$service->enabled()) {
392
            return;
393
        }
394
395
        if ($this->owner->Username) {
396
            $this->owner->Username = strtolower($this->owner->Username);
397
        }
398
399
        // Ensure the user exists LDAP.
400
        if ($this->owner->Username && !$this->owner->IsImportedFromLDAP) {
401
            $this->createUser();
402
            $this->sync();
403
            $this->owner->Creating = true;
404
        }
405
    }
406
407
    /**
408
     * Sync the local data with LDAP, and ensure local membership is also set in
409
     * LDAP too. This writes into LDAP, provided reverse sync is enabled.
410
     */
411
    public function onAfterWrite()
412
    {
413
        if (!$this->owner->config()->reverse_sync_ldap) {
414
            return;
415
        }
416
        if ($this->owner->IsImportedFromLDAP) {
417
            $this->sync();
418
            $this->syncGroups();
419
        }
420
421
        if (
422
            !$this->owner->Creating ||
423
            !$this->owner->IsImportedFromLDAP
424
        ) {
425
            return;
426
        }
427
428
        // muzdowski: Creation is entering the last phase. I have seen framework trying to ->write
429
        // twice in a row, resulting in an irrelevant error second time around. Mark creation
430
        // explicitly as done to prevent that.
431
        $this->owner->Creating = false;
432
    }
433
434
    /**
435
     * Triggered by {@link Member::logIn()} when successfully logged in,
436
     * this will update the Member record from AD data.
437
     */
438
    public function memberLoggedIn()
439
    {
440
        if ($this->owner->GUID) {
441
            $this->ldapService->updateMemberFromLDAP($this->owner);
442
        }
443
    }
444
}
445