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

LDAPMemberExtension::memberLoggedIn()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
c 2
b 0
f 0
dl 0
loc 6
rs 9.4285
cc 2
eloc 3
nc 2
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
        'givenname' => 'FirstName',
30
        'samaccountname' => 'Username',
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 = array('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 && !$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...
112
            // Transform the automatically mapped fields into read-only. This doesn't
113
            // apply if reverse sync is enabled, as changing data locally can be written back.
114
            foreach ($this->owner->config()->ldap_field_mappings as $name) {
115
                $field = $fields->dataFieldByName($name);
116
                if (!empty($field)) {
117
                    // Set to readonly, but not disabled so that the data is still sent to the
118
                    // server and doesn't break Member_Validator
119
                    $field->setReadonly(true);
120
                    $field->setTitle($field->Title()._t('LDAPMemberExtension.IMPORTEDFIELD', ' (imported)'));
121
                }
122
            }
123
124
            // Display alert message at the top. This is not applicable if reverse sync is
125
            $message = _t(
126
                'LDAPMemberExtension.INFOIMPORTED',
127
                'This user is automatically imported from LDAP. '.
128
                    'Manual changes to imported fields will be removed upon sync.'
129
            );
130
            $fields->addFieldToTab(
131
                'Root.Main',
132
                new LiteralField(
133
                    'Info',
134
                    sprintf('<p class="message warning">%s</p>', $message)
135
                ),
136
                'FirstName'
137
            );
138
        }
139
    }
140
141
    /**
142
     * Creates a new LDAP user given the current Member details.
143
     */
144
    public function createUser()
145
    {
146
        $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...
147
        if (!$service->enabled()) {
148
            return;
149
        }
150
151
        // Create user in LDAP using available information.
152
        $dn = sprintf('CN=%s,%s', $this->owner->Username, LDAP_NEW_USERS_DN);
153
154
        try {
155
            $service->add($dn, [
156
                'objectclass' => 'user',
157
                'cn' => $this->owner->Username,
158
                'instancetype' => '4',
159
                'codepage' => '0',
160
                'countrycode' => '0',
161
                'accountexpires' => '9223372036854775807',
162
                'userprincipalname' => sprintf('%s@%s', $this->owner->Username, LDAP_DOMAIN),
163
                'objectcategory' => LDAP_NEW_USERS_OBJECT_CATEGORY,
164
                'useraccountcontrol' => '66048',
165
                'distinguishedname' => $dn,
166
            ]);
167
        } catch (\Exception $e) {
168
            throw new ValidationException('LDAP synchronisation failure: '.$e->getMessage());
169
        }
170
171
        $user = $service->getUserByUsername($this->owner->Username);
172
        if (empty($user['objectguid'])) {
173
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
174
        }
175
176
        // Creation was successful, mark the user as synced with LDAP.
177
        $this->owner->GUID = $user['objectguid'];
178
        $this->owner->IsImportedFromLDAP = 1;
179
    }
180
181
    /**
182
     * Sync the Member data back to the corresponding LDAP user object.
183
     * @throws ValidationException
184
     */
185
    public function sync()
186
    {
187
        $service = $this->ldapService;
188
        if (!$service->enabled()) {
189
            return;
190
        }
191
        if (!$this->owner->IsImportedFromLDAP) {
192
            return;
193
        }
194
195
        $data = $service->getUserByGUID($this->owner->GUID);
196
        if (empty($data['objectguid'])) {
197
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
198
        }
199
200
        $dn = $data['distinguishedname'];
201
202
        try {
203
            // If the common name (cn) has changed, we need to ensure they've been moved
204
            // to the new DN, to avoid any clashes between user objects.
205
            if ($data['cn'] != $this->owner->Username) {
206
                $newDn = sprintf('CN=%s,%s', $this->owner->Username, preg_replace('/^CN=(.+?),/', '', $dn));
207
                $service->move($dn, $newDn);
208
                $dn = $newDn;
209
            }
210
211
            $attributes = array(
212
                'displayname' => sprintf('%s %s', $this->owner->FirstName, $this->owner->Surname),
213
                'name' => sprintf('%s %s', $this->owner->FirstName, $this->owner->Surname),
214
                'userprincipalname' => sprintf('%s@%s', $this->owner->Username, LDAP_DOMAIN),
215
            );
216
            foreach ($this->owner->config()->ldap_field_mappings as $attribute => $field) {
217
                $relationClass = $this->owner->getRelationClass($field);
218
                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...
219
                    // todo no support for writing back relations yet.
220
                } else {
221
                    $attributes[$attribute] = $this->owner->$field;
222
                }
223
            }
224
225
            $service->update($dn, $attributes);
226
        } catch (\Exception $e) {
227
            throw new ValidationException('LDAP synchronisation failure: '.$e->getMessage());
228
        }
229
    }
230
231
    public function validate(ValidationResult $validationResult)
232
    {
233
        if (!$this->owner->config()->create_new_users_in_ldap) {
234
            return;
235
        }
236
237
        // We allow empty Username for registration purposes, as we need to
238
        // create Member records with empty Username temporarily. Forms should explicitly
239
        // check for Username not being empty if they require it not to be.
240
        if (empty($this->owner->Username)) {
241
            return;
242
        }
243
244
        if (!preg_match('/^[a-z0-9\.]+$/', $this->owner->Username)) {
245
            $validationResult->error(
246
                'Username must only contain lowercase alphanumeric characters and dots.',
247
                'bad'
248
            );
249
            throw new ValidationException($validationResult);
250
        }
251
    }
252
253
    /**
254
     * Ensure the user belongs to the correct groups in LDAP, making the
255
     * assumption that the assigned groups are correct.
256
     * This is considered a reverse sync back to LDAP.
257
     *
258
     * This also removes them from LDAP groups if they've been taken out of one.
259
     * It will not affect group membership of non-mapped groups, so it will
260
     * not touch such internal AD groups like "Domain Users".
261
     */
262
    public function syncGroups()
263
    {
264
        $service = $this->ldapService;
265
        if (!$service->enabled()) {
266
            return;
267
        }
268
        if (!$this->owner->IsImportedFromLDAP) {
269
            return;
270
        }
271
272
        $addGroups = array();
273
        $removeGroups = array();
274
275
        $user = $service->getUserByGUID($this->owner->GUID);
276
        if (empty($user['objectguid'])) {
277
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
278
        }
279
280
        // If a user belongs to a single group, this comes through as a string.
281
        // Normalise to a array so it's consistent.
282
        $existingGroups = !empty($user['memberof']) ? $user['memberof'] : array();
283
        if ($existingGroups && is_string($existingGroups)) {
284
            $existingGroups = array($existingGroups);
285
        }
286
287
        foreach ($this->owner->Groups() as $group) {
288
            if (!$group->IsImportedFromLDAP) {
289
                continue;
290
            }
291
292
            // mark this group as something we need to ensure the user belongs to in LDAP.
293
            $addGroups[] = $group->DN;
294
        }
295
296
        // Which existing LDAP groups are not in the add groups? We'll check these groups to
297
        // see if the user should be removed from any of them.
298
        $remainingGroups = array_diff($existingGroups, $addGroups);
299
300
        foreach ($remainingGroups as $groupDn) {
301
            // We only want to be removing groups we have a local Group mapped to. Removing
302
            // membership for anything else would be bad!
303
            $group = Group::get()->filter('DN', $groupDn)->first();
304
            if (!$group || !$group->exists()) {
305
                continue;
306
            }
307
308
            // this group should be removed from the user's memberof attribute, as it's been removed.
309
            $removeGroups[] = $groupDn;
310
        }
311
312
        // go through the groups we want the user to be in and ensure they're in them.
313
        foreach ($addGroups as $groupDn) {
314
            $members = $this->getLDAPGroupMembers($groupDn);
315
316
            // this user is already in the group, no need to do anything.
317
            if (in_array($user['distinguishedname'], $members)) {
318
                continue;
319
            }
320
321
            $members[] = $user['distinguishedname'];
322
323
            try {
324
                $service->update($groupDn, array('member' => $members));
325
            } catch (\Exception $e) {
326
                throw new ValidationException('LDAP group membership add failure: '.$e->getMessage());
327
            }
328
        }
329
330
        // go through the groups we _don't_ want the user to be in and ensure they're taken out of them.
331
        foreach ($removeGroups as $groupDn) {
332
            $members = $this->getLDAPGroupMembers($groupDn);
333
334
            // remove the user from the members data.
335
            if (in_array($user['distinguishedname'], $members)) {
336
                foreach ($members as $i => $dn) {
337
                    if ($dn == $user['distinguishedname']) {
338
                        unset($members[$i]);
339
                    }
340
                }
341
            }
342
343
            try {
344
                $service->update($groupDn, array('member' => $members));
345
            } catch (\Exception $e) {
346
                throw new ValidationException('LDAP group membership remove failure: '.$e->getMessage());
347
            }
348
        }
349
    }
350
351
    /**
352
     * Given a group DN, look up the group membership data in LDAP.
353
     *
354
     * @param string $groupDn
355
     *
356
     * @return array
357
     */
358
    protected function getLDAPGroupMembers($groupDn)
359
    {
360
        $service = $this->ldapService;
361
        if (!$service->enabled()) {
362
            return;
363
        }
364
365
        $groupObj = Group::get()->filter('DN', $groupDn)->first();
366
        $groupData = $service->getGroupByGUID($groupObj->GUID);
367
        $members = !empty($groupData['member']) ? $groupData['member'] : array();
368
        // If a user belongs to a single group, this comes through as a string.
369
        // Normalise to a array so it's consistent.
370
        if ($members && is_string($members)) {
371
            $members = array($members);
372
        }
373
374
        return $members;
375
    }
376
377
    /**
378
     * Create the user in LDAP and mark as synced, provided that
379
     * reverse sync is enabled.
380
     *
381
     * Set a flag "Creating" so other extensions using on*() events can
382
     * detect whether it's in a state of being created, such as for
383
     * synchronising with other services when a user is being created
384
     * in LDAP for the first time.
385
     */
386
    public function onBeforeWrite()
387
    {
388
        if (!$this->owner->config()->create_new_users_in_ldap) {
389
            return;
390
        }
391
        $service = $this->ldapService;
392
        if (!$service->enabled()) {
393
            return;
394
        }
395
396
        if ($this->owner->Username) {
397
            $this->owner->Username = strtolower($this->owner->Username);
398
        }
399
400
        // Ensure the user exists LDAP.
401
        if ($this->owner->Username && !$this->owner->IsImportedFromLDAP) {
402
            $this->createUser();
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