Passed
Pull Request — master (#18)
by Matt
02:29
created

LDAPMemberExtension::memberLoggedIn()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 0
dl 0
loc 6
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\LDAP\Extensions;
4
5
use Exception;
6
use SilverStripe\LDAP\Services\LDAPService;
7
use SilverStripe\Core\Injector\Injector;
8
use SilverStripe\Forms\FieldList;
9
use SilverStripe\Forms\LiteralField;
10
use SilverStripe\Forms\ReadonlyField;
11
use SilverStripe\ORM\DataExtension;
12
use SilverStripe\ORM\ValidationResult;
13
use SilverStripe\ORM\ValidationException;
14
use SilverStripe\Security\Member;
15
16
/**
17
 * Class LDAPMemberExtension.
18
 *
19
 * Adds mappings from AD attributes to SilverStripe {@link Member} fields.
20
 *
21
 * @package activedirectory
22
 */
23
class LDAPMemberExtension extends DataExtension
24
{
25
    /**
26
     * @var array
27
     */
28
    private static $db = [
0 ignored issues
show
introduced by
The private property $db is not used, and could be removed.
Loading history...
29
        // Unique user identifier
30
        'GUID' => 'Varchar(50)',
31
        'Username' => 'Varchar(64)',
32
        'IsExpired' => 'Boolean',
33
        'LastSynced' => 'DBDatetime',
34
    ];
35
36
    /**
37
     * These fields are used by {@link LDAPMemberSync} to map specific AD attributes
38
     * to {@link Member} fields.
39
     *
40
     * @var array
41
     * @config
42
     */
43
    private static $ldap_field_mappings = [
44
        'givenname' => 'FirstName',
45
        'samaccountname' => 'Username',
46
        'sn' => 'Surname',
47
        'mail' => 'Email',
48
    ];
49
50
    /**
51
     * The location (relative to /assets) where to save thumbnailphoto data.
52
     *
53
     * @var string
54
     * @config
55
     */
56
    private static $ldap_thumbnail_path = 'Uploads';
0 ignored issues
show
introduced by
The private property $ldap_thumbnail_path is not used, and could be removed.
Loading history...
57
58
    /**
59
     * When enabled, LDAP managed Member records (GUID flag)
60
     * have their data written back to LDAP on write, and synchronise
61
     * membership to groups mapped to LDAP.
62
     *
63
     * Keep in mind this will currently NOT trigger if there are no
64
     * field changes due to onAfterWrite in framework not being called
65
     * when there are no changes.
66
     *
67
     * This requires setting write permissions on the user configured in the LDAP
68
     * credentials, which is why this is disabled by default.
69
     *
70
     * @var bool
71
     * @config
72
     */
73
    private static $update_ldap_from_local = false;
74
75
    /**
76
     * If enabled, Member records with a Username field have the user created in LDAP
77
     * on write.
78
     *
79
     * This requires setting write permissions on the user configured in the LDAP
80
     * credentials, which is why this is disabled by default.
81
     *
82
     * @var bool
83
     * @config
84
     */
85
    private static $create_users_in_ldap = false;
86
87
    /**
88
     * If enabled, deleting Member records mapped to LDAP deletes the LDAP user.
89
     *
90
     * This requires setting write permissions on the user configured in the LDAP
91
     * credentials, which is why this is disabled by default.
92
     *
93
     * @var bool
94
     * @config
95
     */
96
    private static $delete_users_in_ldap = false;
97
98
    /**
99
     * If enabled, this allows the afterMemberLoggedIn() call to fail to update the user without causing a login failure
100
     * and server error. This can be useful when not all of your web servers have access to the LDAP server (for example
101
     * when your front-line web servers are not the servers that perform the LDAP sync into the database.
102
     *
103
     * Note: If this is enabled, you *must* ensure that a regular sync of both groups and users is carried out by
104
     * running the LDAPGroupSyncTask and LDAPMemberSyncTask. If not, there is no guarantee that this user can still have
105
     * all the permissions that they previously had.
106
     *
107
     * Security risk: If this is enabled, then users who are removed from groups may not have their group membership or
108
     * other information updated until the aforementioned LDAPGroupSyncTask and LDAPMemberSyncTask build tasks are run,
109
     * which can lead to users having incorrect permissions until the next sync happens.
110
     *
111
     * @var bool
112
     * @config
113
     */
114
    private static $allow_update_failure_during_login = false;
115
116
    /**
117
     * @param FieldList $fields
118
     */
119
    public function updateCMSFields(FieldList $fields)
120
    {
121
        // Redo LDAP metadata fields as read-only and move to LDAP tab.
122
        $ldapMetadata = [];
123
        $fields->replaceField('GUID', $ldapMetadata[] = ReadonlyField::create('GUID'));
124
        $fields->replaceField(
125
            'IsExpired',
126
            $ldapMetadata[] = ReadonlyField::create(
127
                'IsExpired',
128
                _t(__CLASS__ . '.ISEXPIRED', 'Has user\'s LDAP/AD login expired?')
129
            )
130
        );
131
        $fields->replaceField(
132
            'LastSynced',
133
            $ldapMetadata[] = ReadonlyField::create(
134
                'LastSynced',
135
                _t(__CLASS__ . '.LASTSYNCED', 'Last synced')
136
            )
137
        );
138
        $fields->addFieldsToTab('Root.LDAP', $ldapMetadata);
139
140
        $message = '';
141
        if ($this->owner->GUID && $this->owner->config()->update_ldap_from_local) {
142
            $message = _t(
143
                __CLASS__ . '.CHANGEFIELDSUPDATELDAP',
144
                'Changing fields here will update them in LDAP.'
145
            );
146
        } elseif ($this->owner->GUID && !$this->owner->config()->update_ldap_from_local) {
147
            // Transform the automatically mapped fields into read-only. This doesn't
148
            // apply if updating LDAP from local is enabled, as changing data locally can be written back.
149
            foreach ($this->owner->config()->ldap_field_mappings as $name) {
150
                $field = $fields->dataFieldByName($name);
151
                if (!empty($field)) {
152
                    // Set to readonly, but not disabled so that the data is still sent to the
153
                    // server and doesn't break Member_Validator
154
                    $field->setReadonly(true);
155
                    $field->setTitle($field->Title()._t(__CLASS__ . '.IMPORTEDFIELD', ' (imported)'));
156
                }
157
            }
158
            $message = _t(
159
                __CLASS__ . '.INFOIMPORTED',
160
                'This user is automatically imported from LDAP. '.
161
                    'Manual changes to imported fields will be removed upon sync.'
162
            );
163
        }
164
        if ($message) {
165
            $fields->addFieldToTab(
166
                'Root.Main',
167
                LiteralField::create(
168
                    'Info',
169
                    sprintf('<p class="message warning">%s</p>', $message)
170
                ),
171
                'FirstName'
172
            );
173
        }
174
    }
175
176
    /**
177
     * @param  ValidationResult
178
     * @throws ValidationException
179
     */
180
    public function validate(ValidationResult $validationResult)
181
    {
182
        // We allow empty Username for registration purposes, as we need to
183
        // create Member records with empty Username temporarily. Forms should explicitly
184
        // check for Username not being empty if they require it not to be.
185
        if (empty($this->owner->Username) || !$this->owner->config()->create_users_in_ldap) {
186
            return;
187
        }
188
189
        if (!preg_match('/^[a-z0-9\.]+$/', $this->owner->Username)) {
190
            $validationResult->addError(
191
                'Username must only contain lowercase alphanumeric characters and dots.',
192
                'bad'
193
            );
194
            throw new ValidationException($validationResult);
195
        }
196
    }
197
198
    /**
199
     * Create the user in LDAP, provided this configuration is enabled
200
     * and a username was passed to a new Member record.
201
     */
202
    public function onBeforeWrite()
203
    {
204
        if ($this->owner->LDAPMemberExtension_NoSync) {
205
            return;
206
        }
207
208
        $service = Injector::inst()->get(LDAPService::class);
209
        if (!$service->enabled()
210
            || !$this->owner->config()->create_users_in_ldap
211
            || !$this->owner->Username
212
            || $this->owner->GUID
213
        ) {
214
            return;
215
        }
216
217
        $service->createLDAPUser($this->owner);
218
    }
219
220
    public function onAfterWrite()
221
    {
222
        if ($this->owner->LDAPMemberExtension_NoSync) {
223
            return;
224
        }
225
226
        $service = Injector::inst()->get(LDAPService::class);
227
        if (!$service->enabled() ||
228
            !$this->owner->config()->update_ldap_from_local ||
229
            !$this->owner->GUID
230
        ) {
231
            return;
232
        }
233
        $this->sync();
234
    }
235
236
    public function onAfterDelete()
237
    {
238
        if ($this->owner->LDAPMemberExtension_NoSync) {
239
            return;
240
        }
241
242
        $service = Injector::inst()->get(LDAPService::class);
243
        if (!$service->enabled() ||
244
            !$this->owner->config()->delete_users_in_ldap ||
245
            !$this->owner->GUID
246
        ) {
247
            return;
248
        }
249
250
        $service->deleteLDAPMember($this->owner);
251
    }
252
253
    /**
254
     * Write DataObject without triggering this extension's hooks.
255
     *
256
     * @throws Exception
257
     */
258
    public function writeWithoutSync()
259
    {
260
        $this->owner->LDAPMemberExtension_NoSync = true;
261
        try {
262
            $this->owner->write();
263
        } finally {
264
            $this->owner->LDAPMemberExtension_NoSync = false;
265
        }
266
    }
267
268
    /**
269
     * Update the local data with LDAP, and ensure local membership is also set in
270
     * LDAP too. This writes into LDAP, provided that feature is enabled.
271
     */
272
    public function sync()
273
    {
274
        $service = Injector::inst()->get(LDAPService::class);
275
        if (!$service->enabled() ||
276
            !$this->owner->GUID
277
        ) {
278
            return;
279
        }
280
        $service->updateLDAPFromMember($this->owner);
281
        $service->updateLDAPGroupsForMember($this->owner);
282
    }
283
284
    /**
285
     * Triggered by {@link IdentityStore::logIn()}. When successfully logged in,
286
     * this will update the Member record from LDAP data.
287
     */
288
    public function afterMemberLoggedIn()
289
    {
290
        if ($this->owner->GUID) {
291
            try {
292
                Injector::inst()->get(LDAPService::class)->updateMemberFromLDAP($this->owner);
293
            } catch (Exception $e) {
294
                // If the failure is acceptable, then ignore it and return. Otherwise, re-throw the exception
295
                if ($this->owner->config()->allow_update_failure_during_login) {
296
                    return;
297
                } else {
298
                    throw $e;
299
                }
300
            }
301
        }
302
    }
303
304
    /**
305
     * Synchronise password changes to AD when they happen in SilverStripe
306
     *
307
     * @param string           $newPassword
308
     * @param ValidationResult $validation
309
     */
310
    public function onBeforeChangePassword($newPassword, $validation)
311
    {
312
        // Don't do anything if there's already a validation failure
313
        if (!$validation->isValid()) {
314
            return;
315
        }
316
317
        Injector::inst()->get(LDAPService::class)
318
            ->setPassword($this->owner, $newPassword);
319
    }
320
}
321