Completed
Pull Request — master (#64)
by Sean
05:31
created

LDAPMemberExtension::validate()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 21
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 21
rs 9.0534
cc 4
eloc 10
nc 4
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, any user that is in LDAP has their data written back
45
     * to LDAP. This is a push to LDAP, rather than {@link LDAPMemberSyncTask}
46
     * which pulls from it. This also requires setting write permissions on the
47
     * user who talks to LDAP, which is why it is disabled by default.
48
     *
49
     * Note that some constants must be configured in your environment file
50
     * for this to work:
51
     *
52
     * LDAP_DOMAIN - the base DN of the directory. e.g. "DN=mydomain,DC=com"
53
     * LDAP_NEW_USERS_OBJECT_CATEGORY - the type of object. e.g. "CN=Person,CN=Schema,DC=mydomain,DC=com"
54
     * LDAP_NEW_USERS_DN - where to place users in the directory. e.g. "OU=Users,DC=mydomain,DC=com"
55
     *
56
     * @var bool
57
     * @config
58
     */
59
    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...
60
61
    /**
62
     * If enabled, new users written are also created in LDAP.
63
     * Please see reverse_sync_ldap for constants that must be configured in
64
     * your environment file for this to work.
65
     *
66
     * @var bool
67
     * @config
68
     */
69
    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...
70
71
    /**
72
     * @var array
73
     */
74
    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...
75
        'ldapService' => '%$LDAPService',
76
    );
77
78
    public function updateSummaryFields(&$fields)
79
    {
80
        $fields = ['Username' => 'Username'] + $fields;
81
    }
82
83
    /**
84
     * @param FieldList $fields
85
     */
86
    public function updateCMSFields(FieldList $fields)
87
    {
88
        // Redo LDAP metadata fields as read-only and move to LDAP tab.
89
        $ldapMetadata = array();
90
        $fields->replaceField('IsImportedFromLDAP', $ldapMetadata[] = new ReadonlyField(
91
            'IsImportedFromLDAP',
92
            _t('LDAPMemberExtension.ISIMPORTEDFROMLDAP', 'Is user imported from LDAP/AD?')
93
        ));
94
        $fields->replaceField('GUID', $ldapMetadata[] = new ReadonlyField('GUID'));
95
        $fields->replaceField('IsExpired', $ldapMetadata[] = new ReadonlyField(
96
            'IsExpired',
97
            _t('LDAPMemberExtension.ISEXPIRED', 'Has user\'s LDAP/AD login expired?'))
98
        );
99
        $fields->replaceField('LastSynced', $ldapMetadata[] = new ReadonlyField(
100
            'LastSynced',
101
            _t('LDAPMemberExtension.LASTSYNCED', 'Last synced'))
102
        );
103
        $fields->addFieldsToTab('Root.LDAP', $ldapMetadata);
104
105
        if ($this->owner->IsImportedFromLDAP) {
106
            // Transform the automatically mapped fields into read-only.
107
            foreach ($this->owner->config()->ldap_field_mappings as $name) {
108
                $field = $fields->dataFieldByName($name);
109
                if (!empty($field)) {
110
                    // Set to readonly, but not disabled so that the data is still sent to the
111
                    // server and doesn't break Member_Validator
112
                    $field->setReadonly(true);
113
                    $field->setTitle($field->Title()._t('LDAPMemberExtension.IMPORTEDFIELD', ' (imported)'));
114
                }
115
            }
116
117
            // Display alert message at the top.
118
            $message = _t(
119
                'LDAPMemberExtension.INFOIMPORTED',
120
                'This user is automatically imported from LDAP. '.
121
                    'Manual changes to imported fields will be removed upon sync.'
122
            );
123
            $fields->addFieldToTab(
124
                'Root.Main',
125
                new LiteralField(
126
                    'Info',
127
                    sprintf('<p class="message warning">%s</p>', $message)
128
                ),
129
                'FirstName'
130
            );
131
        }
132
    }
133
134
    /**
135
     * Creates a new LDAP user given the current Member details. Assumption is
136
     * the record has been validated for the presence of FirstName, Surname, Email,
137
     * and Username prior to the request being sent to LDAP.
138
     */
139
    public function createUser()
140
    {
141
        $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...
142
        if (!$service->enabled()) {
143
            return;
144
        }
145
146
        // Create user in LDAP using available information.
147
        $dn = sprintf('CN=%s,%s', $this->owner->Username, LDAP_NEW_USERS_DN);
148
149
        try {
150
            $service->add($dn, [
151
                'objectClass' => 'user',
152
                'cn' => $this->owner->Username,
153
                'sn' => $this->owner->Surname,
154
                'givenName' => $this->owner->FirstName,
155
                'instanceType' => '4',
156
                'displayName' => sprintf('%s %s', $this->owner->FirstName, $this->owner->Surname),
157
                'name' => sprintf('%s %s', $this->owner->FirstName, $this->owner->Surname),
158
                'codePage' => '0',
159
                'countryCode' => '0',
160
                'accountExpires' => '9223372036854775807',
161
                'sAMAccountName' => $this->owner->Username,
162
                'userPrincipalName' => sprintf('%s@%s', $this->owner->Username, LDAP_DOMAIN),
163
                'objectCategory' => LDAP_NEW_USERS_OBJECT_CATEGORY,
164
                'userAccountControl' => '66048',
165
                'mail' => $this->owner->Email,
166
                'distinguishedName' => $dn,
167
            ]);
168
        } catch (\Exception $e) {
169
            throw new ValidationException('LDAP synchronisation failure: '.$e->getMessage());
170
        }
171
172
        $user = $service->getUserByUsername($this->owner->Username);
173
        if (empty($user['objectguid'])) {
174
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
175
        }
176
177
        // Creation was successful, mark the user as synced with LDAP.
178
        $this->owner->GUID = $user['objectguid'];
179
        $this->owner->IsImportedFromLDAP = 1;
180
    }
181
182
    /**
183
     * Sync the Member data back to the corresponding LDAP user object.
184
     *
185
     * This is effectively a reverse sync, so we don't want to be doing
186
     * this onBeforeWrite as LDAPMemberSyncTask could get it into a loop.
187
     * This method should be called explicitly when a sync of the
188
     * Platform Dashboard user back to LDAP is required.
189
     *
190
     * @throws ValidationException
191
     */
192
    public function sync()
193
    {
194
        $service = $this->ldapService;
195
        if (!$service->enabled()) {
196
            return;
197
        }
198
        if (!$this->owner->IsImportedFromLDAP) {
199
            return;
200
        }
201
202
        $data = $service->getUserByGUID($this->owner->GUID);
203
        if (empty($data['objectguid'])) {
204
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
205
        }
206
207
        $dn = $data['distinguishedname'];
208
209
        try {
210
            // If the common name (cn) has changed, we need to ensure they've been moved
211
            // to the new DN, to avoid any clashes between user objects.
212
            if ($data['cn'] != $this->owner->Username) {
213
                $newDn = sprintf('CN=%s,%s', $this->owner->Username, preg_replace('/^CN=(.+?),/', '', $dn));
214
                $service->move($dn, $newDn);
215
                $dn = $newDn;
216
            }
217
218
            $service->update($dn, [
219
                'sn' => $this->owner->Surname,
220
                'givenName' => $this->owner->FirstName,
221
                'displayName' => sprintf('%s %s', $this->owner->FirstName, $this->owner->Surname),
222
                'name' => sprintf('%s %s', $this->owner->FirstName, $this->owner->Surname),
223
                'sAMAccountName' => $this->owner->Username,
224
                'userPrincipalName' => sprintf('%s@%s', $this->owner->Username, LDAP_DOMAIN),
225
                'mail' => $this->owner->Email,
226
            ]);
227
        } catch (\Exception $e) {
228
            throw new ValidationException('LDAP synchronisation failure: '.$e->getMessage());
229
        }
230
    }
231
232
    public function validate(ValidationResult $validationResult)
233
    {
234
        if (!$this->owner->config()->create_new_users_in_ldap) {
235
            return;
236
        }
237
238
        // We allow empty Username for registration purposes, as we need to
239
        // create Member records with empty Username temporarily. Forms should explicitly
240
        // check for Username not being empty if they require it not to be.
241
        if (empty($this->owner->Username)) {
242
            return;
243
        }
244
245
        if (!preg_match('/^[a-z0-9\.]+$/', $this->owner->Username)) {
246
            $validationResult->error(
247
                'Username must only contain lowercase alphanumeric characters and dots.',
248
                'bad'
249
            );
250
            throw new ValidationException($validationResult);
251
        }
252
    }
253
254
    /**
255
     * Ensure the user belongs to the correct groups in LDAP, making the
256
     * assumption that the assigned groups are correct.
257
     * This is considered a reverse sync back to LDAP.
258
     *
259
     * This also removes them from LDAP groups if they've been taken out of one.
260
     * It will not affect group membership of non-mapped groups, so it will
261
     * not touch such internal AD groups like "Domain Users".
262
     */
263
    public function syncGroups()
264
    {
265
        $service = $this->ldapService;
266
        if (!$service->enabled()) {
267
            return;
268
        }
269
        if (!$this->owner->IsImportedFromLDAP) {
270
            return;
271
        }
272
273
        $addGroups = array();
274
        $removeGroups = array();
275
276
        $user = $service->getUserByGUID($this->owner->GUID);
277
        if (empty($user['objectguid'])) {
278
            throw new ValidationException('LDAP synchronisation failure: user missing GUID');
279
        }
280
281
        // If a user belongs to a single group, this comes through as a string.
282
        // Normalise to a array so it's consistent.
283
        $existingGroups = !empty($user['memberof']) ? $user['memberof'] : array();
284
        if ($existingGroups && is_string($existingGroups)) {
285
            $existingGroups = array($existingGroups);
286
        }
287
288
        foreach ($this->owner->Groups() as $group) {
289
            if (!$group->IsImportedFromLDAP) {
290
                continue;
291
            }
292
293
            // mark this group as something we need to ensure the user belongs to in LDAP.
294
            $addGroups[] = $group->DN;
295
        }
296
297
        // Which existing LDAP groups are not in the add groups? We'll check these groups to
298
        // see if the user should be removed from any of them.
299
        $remainingGroups = array_diff($existingGroups, $addGroups);
300
301
        foreach ($remainingGroups as $groupDn) {
302
            // We only want to be removing groups we have a local Group mapped to. Removing
303
            // membership for anything else would be bad!
304
            $group = Group::get()->filter('DN', $groupDn)->first();
305
            if (!$group || !$group->exists()) {
306
                continue;
307
            }
308
309
            // this group should be removed from the user's memberof attribute, as it's been removed.
310
            $removeGroups[] = $groupDn;
311
        }
312
313
        // go through the groups we want the user to be in and ensure they're in them.
314
        foreach ($addGroups as $groupDn) {
315
            $members = $this->getLDAPGroupMembers($groupDn);
316
317
            // this user is already in the group, no need to do anything.
318
            if (in_array($user['distinguishedname'], $members)) {
319
                continue;
320
            }
321
322
            $members[] = $user['distinguishedname'];
323
324
            try {
325
                $service->update($groupDn, array('member' => $members));
326
            } catch (\Exception $e) {
327
                throw new ValidationException('LDAP group membership add failure: '.$e->getMessage());
328
            }
329
        }
330
331
        // go through the groups we _don't_ want the user to be in and ensure they're taken out of them.
332
        foreach ($removeGroups as $groupDn) {
333
            $members = $this->getLDAPGroupMembers($groupDn);
334
335
            // remove the user from the members data.
336
            if (in_array($user['distinguishedname'], $members)) {
337
                foreach ($members as $i => $dn) {
338
                    if ($dn == $user['distinguishedname']) {
339
                        unset($members[$i]);
340
                    }
341
                }
342
            }
343
344
            try {
345
                $service->update($groupDn, array('member' => $members));
346
            } catch (\Exception $e) {
347
                throw new ValidationException('LDAP group membership remove failure: '.$e->getMessage());
348
            }
349
        }
350
    }
351
352
    /**
353
     * Given a group DN, look up the group membership data in LDAP.
354
     *
355
     * @param string $groupDn
356
     *
357
     * @return array
358
     */
359
    protected function getLDAPGroupMembers($groupDn)
360
    {
361
        $service = $this->ldapService;
362
        if (!$service->enabled()) {
363
            return;
364
        }
365
366
        $groupObj = Group::get()->filter('DN', $groupDn)->first();
367
        $groupData = $service->getGroupByGUID($groupObj->GUID);
368
        $members = !empty($groupData['member']) ? $groupData['member'] : array();
369
        // If a user belongs to a single group, this comes through as a string.
370
        // Normalise to a array so it's consistent.
371
        if ($members && is_string($members)) {
372
            $members = array($members);
373
        }
374
375
        return $members;
376
    }
377
378
    /**
379
     * Create the user in LDAP and mark as synced, provided that
380
     * reverse sync is enabled.
381
     *
382
     * Set a flag "Creating" so other extensions using on*() events can
383
     * detect whether it's in a state of being created, such as for
384
     * synchronising with other services when a user is being created
385
     * in LDAP for the first time.
386
     */
387
    public function onBeforeWrite()
388
    {
389
        if (!$this->owner->config()->create_new_users_in_ldap) {
390
            return;
391
        }
392
        $service = $this->ldapService;
393
        if (!$service->enabled()) {
394
            return;
395
        }
396
397
        if ($this->owner->Username) {
398
            $this->owner->Username = strtolower($this->owner->Username);
399
        }
400
401
        // Ensure the user exists LDAP.
402
        if ($this->owner->Username && !$this->owner->IsImportedFromLDAP) {
403
            $this->createUser();
404
            $this->owner->Creating = true;
405
        }
406
    }
407
408
    /**
409
     * Sync the local data with LDAP, and ensure local membership is also set in
410
     * LDAP too. This writes into LDAP, provided reverse sync is enabled.
411
     */
412
    public function onAfterWrite()
413
    {
414
        if (!$this->owner->config()->reverse_sync_ldap) {
415
            return;
416
        }
417
        if ($this->owner->IsImportedFromLDAP) {
418
            $this->sync();
419
            $this->syncGroups();
420
        }
421
422
        if (
423
            !$this->owner->Creating ||
424
            !$this->owner->IsImportedFromLDAP
425
        ) {
426
            return;
427
        }
428
429
        // muzdowski: Creation is entering the last phase. I have seen framework trying to ->write
430
        // twice in a row, resulting in an irrelevant error second time around. Mark creation
431
        // explicitly as done to prevent that.
432
        $this->owner->Creating = false;
433
    }
434
435
    /**
436
     * Triggered by {@link Member::logIn()} when successfully logged in,
437
     * this will update the Member record from AD data.
438
     */
439
    public function memberLoggedIn()
440
    {
441
        if ($this->owner->GUID) {
442
            $this->ldapService->updateMemberFromLDAP($this->owner);
443
        }
444
    }
445
}
446