Passed
Push — master ( b2198e...e8e6de )
by Robbie
02:50 queued 10s
created

src/Extensions/LDAPMemberExtension.php (1 issue)

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