Completed
Push — master ( d4e81f...550fff )
by Chad
05:10
created

LdapUserProvider::hasGroupForRoles()   B

Complexity

Conditions 6
Paths 9

Size

Total Lines 20
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 20
c 0
b 0
f 0
rs 8.8571
cc 6
eloc 13
nc 9
nop 2
1
<?php
2
/**
3
 * This file is part of the LdapToolsBundle package.
4
 *
5
 * (c) Chad Sikorra <[email protected]>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 */
10
11
namespace LdapTools\Bundle\LdapToolsBundle\Security\User;
12
13
use LdapTools\BatchModify\BatchCollection;
14
use LdapTools\Bundle\LdapToolsBundle\Event\LoadUserEvent;
15
use LdapTools\Exception\EmptyResultException;
16
use LdapTools\Exception\MultiResultException;
17
use LdapTools\Object\LdapObject;
18
use LdapTools\Object\LdapObjectCollection;
19
use LdapTools\Object\LdapObjectType;
20
use LdapTools\Utilities\LdapUtilities;
21
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
22
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
23
use Symfony\Component\Security\Core\User\UserInterface;
24
use Symfony\Component\Security\Core\User\UserProviderInterface;
25
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
26
use LdapTools\LdapManager;
27
28
/**
29
 * Loads a user from LDAP.
30
 *
31
 * @author Chad Sikorra <[email protected]>
32
 */
33
class LdapUserProvider implements UserProviderInterface
34
{
35
    /**
36
     * @var LdapManager
37
     */
38
    protected $ldap;
39
40
    /**
41
     * @var EventDispatcherInterface
42
     */
43
    protected $dispatcher;
44
45
    /**
46
     * @var array The role to LDAP group name map.
47
     */
48
    protected $roleMap = [];
49
50
    /**
51
     * @var array Map names to their LDAP attribute names when querying for LDAP groups used for roles.
52
     */
53
    protected $roleAttrMap = [
54
        'name' => 'name',
55
        'sid' => 'sid',
56
        'guid' => 'guid',
57
        'members' => 'members',
58
    ];
59
60
    /**
61
     * @var array Default attributes selected for the Advanced User Interface.
62
     */
63
    protected $defaultAttributes = [
64
        'username',
65
        'guid',
66
        'accountExpirationDate',
67
        'enabled',
68
        'groups',
69
        'locked',
70
        'passwordMustChange',
71
    ];
72
73
    /**
74
     * @var array Any additional LDAP attributes to select.
75
     */
76
    protected $additionalAttributes = [];
77
78
    /**
79
     * @var string
80
     */
81
    protected $userClass = LdapUser::class;
82
83
    /**
84
     * @var bool Whether or not to check group membership recursively when checking role membership.
85
     */
86
    protected $checkGroupsRecursively;
87
88
    /**
89
     * @var string|null The default role to be assigned to a user.
90
     */
91
    protected $defaultRole;
92
93
    /**
94
     * @var string The object type to search LDAP for.
95
     */
96
    protected $ldapObjectType = LdapObjectType::USER;
97
98
    /**
99
     * @var string The group object type when searching group membership.
100
     */
101
    protected $groupObjectType = LdapObjectType::GROUP;
102
    
103
    /**
104
     * @var string The container/OU to search for the user under.
105
     */
106
    protected $searchBase;
107
108
    /**
109
     * @var bool Whether or not user attributes should be re-queried on a refresh.
110
     */
111
    protected $refreshAttributes = true;
112
113
    /**
114
     * @var bool Whether or not user roles should be re-queried on a refresh.
115
     */
116
    protected $refreshRoles = true;
117
118
    /**
119
     * @param LdapManager $ldap
120
     * @param EventDispatcherInterface $dispatcher
121
     * @param array $roleMap
122
     * @param bool $checkGroupsRecursively
123
     */
124
    public function __construct(LdapManager $ldap, EventDispatcherInterface $dispatcher, array $roleMap, $checkGroupsRecursively = true)
125
    {
126
        $this->ldap = $ldap;
127
        $this->dispatcher = $dispatcher;
128
        $this->roleMap = $roleMap;
129
        $this->checkGroupsRecursively = $checkGroupsRecursively;
130
    }
131
132
    /**
133
     * Set the default role to add to a LDAP user.
134
     *
135
     * @param string|null $role
136
     */
137
    public function setDefaultRole($role)
138
    {
139
        if (is_string($role)) {
140
            $role = strtoupper($role);
141
        }
142
        $this->defaultRole = $role;
143
    }
144
145
    /**
146
     * Set the user class to be instantiated and returned from the LDAP provider.
147
     *
148
     * @param string $class
149
     */
150
    public function setUserClass($class)
151
    {
152
        if (!$this->supportsClass($class)) {
153
            throw new UnsupportedUserException(sprintf(
154
                'The LDAP user provider class "%s" must implement "%s".',
155
                $class,
156
                LdapUserInterface::class
157
            ));
158
        }
159
160
        $this->userClass = $class;
161
    }
162
163
    /**
164
     * Set any additional attributes to be selected for the LDAP user.
165
     *
166
     * @param array $attributes
167
     */
168
    public function setAttributes(array $attributes)
169
    {
170
        $this->additionalAttributes = $attributes;
171
    }
172
173
    /**
174
     * Set the LDAP object type that will be searched for.
175
     *
176
     * @param string $type
177
     */
178
    public function setLdapObjectType($type)
179
    {
180
        $this->ldapObjectType = $type;
181
    }
182
183
    /**
184
     * Set the LdapTools object type to search for group membership.
185
     *
186
     * @param string $type
187
     */
188
    public function setRoleLdapType($type)
189
    {
190
        $this->groupObjectType = $type;
191
    }
192
193
    /**
194
     * Set the attribute name to LDAP name attributes used in querying LDAP groups for roles.
195
     *
196
     * @param array $map
197
     */
198
    public function setRoleAttributeMap(array $map)
199
    {
200
        $this->roleAttrMap = $map;
201
    }
202
203
    /**
204
     * @param string $searchBase
205
     */
206
    public function setSearchBase($searchBase)
207
    {
208
        $this->searchBase = $searchBase;
209
    }
210
211
    /**
212
     * @param bool $refreshRoles
213
     */
214
    public function setRefreshRoles($refreshRoles)
215
    {
216
        $this->refreshRoles = $refreshRoles;
217
    }
218
219
    /**
220
     * @param bool $refreshAttributes
221
     */
222
    public function setRefreshAttributes($refreshAttributes)
223
    {
224
        $this->refreshAttributes = $refreshAttributes;
225
    }
226
227
    /**
228
     * {@inheritdoc}
229
     */
230
    public function loadUserByUsername($username)
231
    {
232
        $this->dispatcher->dispatch(LoadUserEvent::BEFORE, new LoadUserEvent($username, $this->ldap->getDomainContext()));
233
        $ldapUser = $this->getLdapUser('username', $username);
234
        $user = $this->setRolesForUser($this->constructUserClass($ldapUser));
235
        $this->dispatcher->dispatch(LoadUserEvent::AFTER, new LoadUserEvent($username, $this->ldap->getDomainContext(), $user, $ldapUser));
0 ignored issues
show
Documentation introduced by
$user is of type object<LdapTools\Bundle\...User\LdapUserInterface>, but the function expects a null|object<Symfony\Comp...ore\User\UserInterface>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
236
237
        return $user;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $user; (LdapTools\Bundle\LdapToo...\User\LdapUserInterface) is incompatible with the return type declared by the interface Symfony\Component\Securi...ace::loadUserByUsername of type Symfony\Component\Security\Core\User\UserInterface.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
238
    }
239
240
    /**
241
     * {@inheritdoc}
242
     */
243
    public function refreshUser(UserInterface $user)
244
    {
245
        if (!$user instanceof LdapUserInterface) {
246
            throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_class($user)));
247
        }
248
        $roles = $user->getRoles();
249
250
        if ($this->refreshAttributes) {
251
            $user = $this->constructUserClass($this->getLdapUser('guid', $user->getLdapGuid()));
252
        }
253
        if ($this->refreshRoles) {
254
            $this->setRolesForUser($user);
255
        } else {
256
            $user->setRoles($roles);
257
        }
258
259
        return $user;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $user; (LdapTools\Bundle\LdapToo...\User\LdapUserInterface) is incompatible with the return type declared by the interface Symfony\Component\Securi...rInterface::refreshUser of type Symfony\Component\Security\Core\User\UserInterface.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
260
    }
261
262
    /**
263
     * {@inheritdoc}
264
     */
265
    public function supportsClass($class)
266
    {
267
        return is_subclass_of($class, LdapUserInterface::class);
0 ignored issues
show
Bug introduced by
Due to PHP Bug #53727, is_subclass_of might return inconsistent results on some PHP versions if \LdapTools\Bundle\LdapTo...dapUserInterface::class can be an interface. If so, you could instead use ReflectionClass::implementsInterface.
Loading history...
268
    }
269
270
    /**
271
     * Search for, and return, the LDAP user by a specific attribute.
272
     *
273
     * @param string $attribute
274
     * @param string $value
275
     * @return LdapObject
276
     */
277
    protected function getLdapUser($attribute, $value)
278
    {
279
        try {
280
            $query = $this->ldap->buildLdapQuery()
281
                ->select($this->getAttributesToSelect())
282
                ->from($this->ldapObjectType)
283
                ->where([$attribute => $value]);
284
            if (!is_null($this->searchBase)) {
285
                $query->setBaseDn($this->searchBase);
286
            }
287
            return $query->getLdapQuery()->getSingleResult();
0 ignored issues
show
Bug Compatibility introduced by
The expression $query->getLdapQuery()->getSingleResult(); of type array|LdapTools\Object\LdapObject adds the type array to the return on line 287 which is incompatible with the return type documented by LdapTools\Bundle\LdapToo...erProvider::getLdapUser of type LdapTools\Object\LdapObject.
Loading history...
288
        } catch (EmptyResultException $e) {
289
            throw new UsernameNotFoundException(sprintf('Username "%s" was not found.', $value));
290
        } catch (MultiResultException $e) {
291
            throw new UsernameNotFoundException(sprintf('Multiple results for "%s" were found.', $value));
292
        }
293
    }
294
295
    /**
296
     * Get all the attributes that should be selected for when querying LDAP.
297
     *
298
     * @return array
299
     */
300
    protected function getAttributesToSelect()
301
    {
302
        return array_values(array_unique(array_filter(array_merge(
303
            $this->defaultAttributes,
304
            $this->additionalAttributes
305
        ))));
306
    }
307
308
    /**
309
     * Set the roles for the user based on group membership.
310
     *
311
     * @param LdapUserInterface $user
312
     * @return LdapUserInterface
313
     */
314
    protected function setRolesForUser(LdapUserInterface $user)
315
    {
316
        $roles = [];
317
318
        if ($this->defaultRole) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->defaultRole of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
319
            $roles[] = $this->defaultRole;
320
        }
321
        $groups = $this->getGroupsForUser($user);
322
323
        foreach ($this->roleMap as $role => $roleGroups) {
324
            if ($this->hasGroupForRoles($roleGroups, $groups)) {
325
                $roles[] = $role;
326
            }
327
        }
328
        $user->setRoles($roles);
329
330
        return $user;
331
    }
332
333
    /**
334
     * Check all of the groups that are valid for a specific role against all of the LDAP groups that the user belongs
335
     * to.
336
     *
337
     * @param array $roleGroups
338
     * @param LdapObjectCollection $ldapGroups
339
     * @return bool
340
     */
341
    protected function hasGroupForRoles(array $roleGroups, LdapObjectCollection $ldapGroups)
342
    {
343
        foreach ($roleGroups as $roleGroup) {
344
            if (LdapUtilities::isValidLdapObjectDn($roleGroup)) {
345
                $attribute = 'dn';
346
            } elseif (preg_match(LdapUtilities::MATCH_GUID, $roleGroup)) {
347
                $attribute = $this->roleAttrMap['guid'];
348
            } elseif (preg_match(LdapUtilities::MATCH_SID, $roleGroup)) {
349
                $attribute = $this->roleAttrMap['sid'];
350
            } else {
351
                $attribute = $this->roleAttrMap['name'];
352
            }
353
354
            if ($this->hasGroupWithAttributeValue($ldapGroups, $attribute, $roleGroup)) {
355
                return true;
356
            }
357
        }
358
        
359
        return false;
360
    }
361
362
    /**
363
     * Check each LDAP group to see if any of them have an attribute with a specific value.
364
     *
365
     * @param LdapObjectCollection $groups
366
     * @param string $attribute
367
     * @param string $value
368
     * @return bool
369
     */
370
    protected function hasGroupWithAttributeValue(LdapObjectCollection $groups, $attribute, $value)
371
    {
372
        $value = strtolower($value);
373
374
        /** @var LdapObject $group */
375
        foreach ($groups as $group) {
376
            if ($group->has($attribute) && strtolower($group->get($attribute)) == $value) {
377
                return true;
378
            }
379
        }
380
381
        return false;
382
    }
383
384
    /**
385
     * @param LdapUserInterface $user
386
     * @return LdapObjectCollection
387
     */
388
    protected function getGroupsForUser(LdapUserInterface $user)
389
    {
390
        $select = $this->roleAttrMap;
391
        unset($select['members']);
392
393
        $query = $this->ldap->buildLdapQuery()
394
            ->from($this->groupObjectType)
395
            ->select(array_values($select));
396
        
397
        if ($this->checkGroupsRecursively) {
398
            $query->where($query->filter()->hasMemberRecursively($user->getLdapGuid(), $this->roleAttrMap['members']));
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class LdapTools\Query\Builder\FilterBuilder as the method hasMemberRecursively() does only exist in the following sub-classes of LdapTools\Query\Builder\FilterBuilder: LdapTools\Query\Builder\ADFilterBuilder. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
399
        } else {
400
            $query->where([$this->roleAttrMap['members'] => $user->getLdapGuid()]);
401
        }
402
        
403
        return $query->getLdapQuery()->getResult();
404
    }
405
406
    /**
407
     * @param LdapObject $ldapObject
408
     * @return LdapUserInterface
409
     */
410
    protected function constructUserClass(LdapObject $ldapObject)
411
    {
412
        $errorMessage = 'Unable to instantiate user class "%s". Error was: %s';
413
414
        try {
415
            /** @var LdapUserInterface $user */
416
            $user = new $this->userClass();
417
            $user->setUsername($ldapObject->get('username'));
418
            $user->setLdapGuid($ldapObject->get('guid'));
419
        } catch (\Throwable $e) {
0 ignored issues
show
Bug introduced by
The class Throwable does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
420
            throw new UnsupportedUserException(sprintf($errorMessage, $this->userClass, $e->getMessage()));
421
        // Unlikely to help much in PHP 5.6, but oh well...
422
        } catch (\Exception $e) {
423
            throw new UnsupportedUserException(sprintf($errorMessage, $this->userClass, $e->getMessage()));
424
        }
425
        // If the class also happens to extends the LdapTools LdapObject class, then set the attributes and type...
426
        if ($user instanceof LdapObject) {
427
            $user->setBatchCollection(new BatchCollection($ldapObject->get('dn')));
428
            $user->refresh($ldapObject->toArray());
429
            // This is to avoid the constructor
430
            $refObject = new \ReflectionObject($user);
431
            $refProperty = $refObject->getProperty('type');
432
            $refProperty->setAccessible(true);
433
            $refProperty->setValue($user, $this->ldapObjectType);
434
        }
435
436
        return $user;
437
    }
438
}
439