Completed
Push — ezp-30928-as_a_developer_i_wan... ( 4d81da...f06b29 )
by
unknown
14:36
created

UserService::loadUsersOfUserGroup()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 45

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
nc 3
nop 4
dl 0
loc 45
rs 9.2
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * File containing the eZ\Publish\Core\Repository\UserService class.
5
 *
6
 * @copyright Copyright (C) eZ Systems AS. All rights reserved.
7
 * @license For full copyright and license information view LICENSE file distributed with this source code.
8
 */
9
namespace eZ\Publish\Core\Repository;
10
11
use DateInterval;
12
use DateTime;
13
use DateTimeImmutable;
14
use DateTimeInterface;
15
use Exception;
16
use eZ\Publish\API\Repository\PermissionResolver;
17
use eZ\Publish\API\Repository\Repository as RepositoryInterface;
18
use eZ\Publish\API\Repository\UserService as UserServiceInterface;
19
use eZ\Publish\API\Repository\Values\Content\Content as APIContent;
20
use eZ\Publish\API\Repository\Values\Content\Location;
21
use eZ\Publish\API\Repository\Values\Content\LocationQuery;
22
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\ContentTypeId as CriterionContentTypeId;
23
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\LocationId as CriterionLocationId;
24
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\LogicalAnd as CriterionLogicalAnd;
25
use eZ\Publish\API\Repository\Values\Content\Query\Criterion\ParentLocationId as CriterionParentLocationId;
26
use eZ\Publish\API\Repository\Values\ContentType\ContentType;
27
use eZ\Publish\API\Repository\Values\ContentType\FieldDefinition;
28
use eZ\Publish\API\Repository\Values\User\PasswordInfo;
29
use eZ\Publish\API\Repository\Values\User\PasswordValidationContext;
30
use eZ\Publish\API\Repository\Values\User\User as APIUser;
31
use eZ\Publish\API\Repository\Values\User\UserCreateStruct as APIUserCreateStruct;
32
use eZ\Publish\API\Repository\Values\User\UserGroup as APIUserGroup;
33
use eZ\Publish\API\Repository\Values\User\UserGroupCreateStruct as APIUserGroupCreateStruct;
34
use eZ\Publish\API\Repository\Values\User\UserGroupUpdateStruct;
35
use eZ\Publish\API\Repository\Values\User\UserTokenUpdateStruct;
36
use eZ\Publish\API\Repository\Values\User\UserUpdateStruct;
37
use eZ\Publish\Core\Base\Exceptions\BadStateException;
38
use eZ\Publish\Core\Base\Exceptions\ContentValidationException;
39
use eZ\Publish\Core\Base\Exceptions\InvalidArgumentException;
40
use eZ\Publish\Core\Base\Exceptions\InvalidArgumentValue;
41
use eZ\Publish\Core\Base\Exceptions\NotFoundException;
42
use eZ\Publish\Core\Base\Exceptions\UnauthorizedException;
43
use eZ\Publish\Core\Base\Exceptions\UserPasswordValidationException;
44
use eZ\Publish\Core\FieldType\User\Value as UserValue;
45
use eZ\Publish\Core\FieldType\User\Type as UserType;
46
use eZ\Publish\Core\Repository\Validator\UserPasswordValidator;
47
use eZ\Publish\Core\Repository\Values\User\User;
48
use eZ\Publish\Core\Repository\Values\User\UserCreateStruct;
49
use eZ\Publish\Core\Repository\Values\User\UserGroup;
50
use eZ\Publish\Core\Repository\Values\User\UserGroupCreateStruct;
51
use eZ\Publish\SPI\Persistence\Content\Location\Handler as LocationHandler;
52
use eZ\Publish\SPI\Persistence\User as SPIUser;
53
use eZ\Publish\SPI\Persistence\User\Handler;
54
use eZ\Publish\SPI\Persistence\User\UserTokenUpdateStruct as SPIUserTokenUpdateStruct;
55
use Psr\Log\LoggerInterface;
56
57
/**
58
 * This service provides methods for managing users and user groups.
59
 *
60
 * @example Examples/user.php
61
 */
62
class UserService implements UserServiceInterface
63
{
64
    /** @var \eZ\Publish\API\Repository\Repository */
65
    protected $repository;
66
67
    /** @var \eZ\Publish\SPI\Persistence\User\Handler */
68
    protected $userHandler;
69
70
    /** @var \eZ\Publish\SPI\Persistence\Content\Location\Handler */
71
    private $locationHandler;
72
73
    /** @var array */
74
    protected $settings;
75
76
    /** @var \Psr\Log\LoggerInterface|null */
77
    protected $logger;
78
79
    /** @var \eZ\Publish\API\Repository\PermissionResolver */
80
    private $permissionResolver;
81
82
    public function setLogger(LoggerInterface $logger = null)
83
    {
84
        $this->logger = $logger;
85
    }
86
87
    /**
88
     * Setups service with reference to repository object that created it & corresponding handler.
89
     *
90
     * @param \eZ\Publish\API\Repository\Repository $repository
91
     * @param \eZ\Publish\SPI\Persistence\User\Handler $userHandler
92
     * @param \eZ\Publish\SPI\Persistence\Content\Location\Handler $locationHandler
93
     * @param array $settings
94
     */
95
    public function __construct(
96
        RepositoryInterface $repository,
97
        PermissionResolver $permissionResolver,
98
        Handler $userHandler,
99
        LocationHandler $locationHandler,
100
        array $settings = []
101
    ) {
102
        $this->repository = $repository;
103
        $this->permissionResolver = $permissionResolver;
104
        $this->userHandler = $userHandler;
105
        $this->locationHandler = $locationHandler;
106
        // Union makes sure default settings are ignored if provided in argument
107
        $this->settings = $settings + [
108
            'defaultUserPlacement' => 12,
109
            'userClassID' => 4, // @todo Rename this settings to swap out "Class" for "Type"
110
            'userGroupClassID' => 3,
111
            'hashType' => APIUser::DEFAULT_PASSWORD_HASH,
112
            'siteName' => 'ez.no',
113
        ];
114
    }
115
116
    /**
117
     * Creates a new user group using the data provided in the ContentCreateStruct parameter.
118
     *
119
     * In 4.x in the content type parameter in the profile is ignored
120
     * - the content type is determined via configuration and can be set to null.
121
     * The returned version is published.
122
     *
123
     * @param \eZ\Publish\API\Repository\Values\User\UserGroupCreateStruct $userGroupCreateStruct a structure for setting all necessary data to create this user group
124
     * @param \eZ\Publish\API\Repository\Values\User\UserGroup $parentGroup
125
     *
126
     * @return \eZ\Publish\API\Repository\Values\User\UserGroup
127
     *
128
     * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException if the authenticated user is not allowed to create a user group
129
     * @throws \eZ\Publish\API\Repository\Exceptions\InvalidArgumentException if the input structure has invalid data
130
     * @throws \eZ\Publish\API\Repository\Exceptions\ContentFieldValidationException if a field in the $userGroupCreateStruct is not valid
131
     * @throws \eZ\Publish\API\Repository\Exceptions\ContentValidationException if a required field is missing or set to an empty value
132
     */
133
    public function createUserGroup(APIUserGroupCreateStruct $userGroupCreateStruct, APIUserGroup $parentGroup)
134
    {
135
        $contentService = $this->repository->getContentService();
136
        $locationService = $this->repository->getLocationService();
137
        $contentTypeService = $this->repository->getContentTypeService();
138
139
        if ($userGroupCreateStruct->contentType === null) {
140
            $userGroupContentType = $contentTypeService->loadContentType($this->settings['userGroupClassID']);
141
            $userGroupCreateStruct->contentType = $userGroupContentType;
142
        }
143
144
        $loadedParentGroup = $this->loadUserGroup($parentGroup->id);
145
146
        if ($loadedParentGroup->getVersionInfo()->getContentInfo()->mainLocationId === null) {
147
            throw new InvalidArgumentException('parentGroup', 'parent user group has no main location');
148
        }
149
150
        $locationCreateStruct = $locationService->newLocationCreateStruct(
151
            $loadedParentGroup->getVersionInfo()->getContentInfo()->mainLocationId
152
        );
153
154
        $this->repository->beginTransaction();
155
        try {
156
            $contentDraft = $contentService->createContent($userGroupCreateStruct, [$locationCreateStruct]);
157
            $publishedContent = $contentService->publishVersion($contentDraft->getVersionInfo());
158
            $this->repository->commit();
159
        } catch (Exception $e) {
160
            $this->repository->rollback();
161
            throw $e;
162
        }
163
164
        return $this->buildDomainUserGroupObject($publishedContent);
165
    }
166
167
    /**
168
     * Loads a user group for the given id.
169
     *
170
     * @param mixed $id
171
     * @param string[] $prioritizedLanguages Used as prioritized language code on translated properties of returned object.
172
     *
173
     * @return \eZ\Publish\API\Repository\Values\User\UserGroup
174
     *
175
     * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException if the authenticated user is not allowed to create a user group
176
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException if the user group with the given id was not found
177
     */
178
    public function loadUserGroup($id, array $prioritizedLanguages = [])
179
    {
180
        $content = $this->repository->getContentService()->loadContent($id, $prioritizedLanguages);
181
182
        return $this->buildDomainUserGroupObject($content);
183
    }
184
185
    /**
186
     * Loads the sub groups of a user group.
187
     *
188
     * @param \eZ\Publish\API\Repository\Values\User\UserGroup $userGroup
189
     * @param int $offset the start offset for paging
190
     * @param int $limit the number of user groups returned
191
     * @param string[] $prioritizedLanguages Used as prioritized language code on translated properties of returned object.
192
     *
193
     * @return \eZ\Publish\API\Repository\Values\User\UserGroup[]
194
     *
195
     * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException if the authenticated user is not allowed to read the user group
196
     */
197
    public function loadSubUserGroups(APIUserGroup $userGroup, $offset = 0, $limit = 25, array $prioritizedLanguages = [])
198
    {
199
        $locationService = $this->repository->getLocationService();
200
201
        $loadedUserGroup = $this->loadUserGroup($userGroup->id);
202
        if (!$this->permissionResolver->canUser('content', 'read', $loadedUserGroup)) {
203
            throw new UnauthorizedException('content', 'read');
204
        }
205
206
        if ($loadedUserGroup->getVersionInfo()->getContentInfo()->mainLocationId === null) {
207
            return [];
208
        }
209
210
        $mainGroupLocation = $locationService->loadLocation(
211
            $loadedUserGroup->getVersionInfo()->getContentInfo()->mainLocationId
212
        );
213
214
        $searchResult = $this->searchSubGroups($mainGroupLocation, $offset, $limit);
215
        if ($searchResult->totalCount == 0) {
216
            return [];
217
        }
218
219
        $subUserGroups = [];
220
        foreach ($searchResult->searchHits as $searchHit) {
221
            $subUserGroups[] = $this->buildDomainUserGroupObject(
222
                $this->repository->getContentService()->internalLoadContent(
0 ignored issues
show
Bug introduced by
The method internalLoadContent() does not exist on eZ\Publish\API\Repository\ContentService. Did you maybe mean loadContent()?

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...
223
                    $searchHit->valueObject->contentInfo->id,
0 ignored issues
show
Documentation introduced by
The property contentInfo does not exist on object<eZ\Publish\API\Re...ory\Values\ValueObject>. Since you implemented __get, maybe consider adding a @property annotation.

Since your code implements the magic getter _get, this function will be called for any read access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

If the property has read access only, you can use the @property-read annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
224
                    $prioritizedLanguages
225
                )
226
            );
227
        }
228
229
        return $subUserGroups;
230
    }
231
232
    /**
233
     * Returns (searches) subgroups of a user group described by its main location.
234
     *
235
     * @param \eZ\Publish\API\Repository\Values\Content\Location $location
236
     * @param int $offset
237
     * @param int $limit
238
     *
239
     * @return \eZ\Publish\API\Repository\Values\Content\Search\SearchResult
240
     */
241
    protected function searchSubGroups(Location $location, $offset = 0, $limit = 25)
242
    {
243
        $searchQuery = new LocationQuery();
244
245
        $searchQuery->offset = $offset;
246
        $searchQuery->limit = $limit;
247
248
        $searchQuery->filter = new CriterionLogicalAnd([
249
            new CriterionContentTypeId($this->settings['userGroupClassID']),
250
            new CriterionParentLocationId($location->id),
251
        ]);
252
253
        $searchQuery->sortClauses = $location->getSortClauses();
0 ignored issues
show
Documentation Bug introduced by
It seems like $location->getSortClauses() of type array<integer,object,{"0":"object"}> is incompatible with the declared type array<integer,object<eZ\...tent\Query\SortClause>> of property $sortClauses.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
254
255
        return $this->repository->getSearchService()->findLocations($searchQuery, [], false);
256
    }
257
258
    /**
259
     * Removes a user group.
260
     *
261
     * the users which are not assigned to other groups will be deleted.
262
     *
263
     * @param \eZ\Publish\API\Repository\Values\User\UserGroup $userGroup
264
     *
265
     * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException if the authenticated user is not allowed to create a user group
266
     */
267
    public function deleteUserGroup(APIUserGroup $userGroup)
268
    {
269
        $loadedUserGroup = $this->loadUserGroup($userGroup->id);
270
271
        $this->repository->beginTransaction();
272
        try {
273
            //@todo: what happens to sub user groups and users below sub user groups
274
            $affectedLocationIds = $this->repository->getContentService()->deleteContent($loadedUserGroup->getVersionInfo()->getContentInfo());
275
            $this->repository->commit();
276
        } catch (Exception $e) {
277
            $this->repository->rollback();
278
            throw $e;
279
        }
280
281
        return $affectedLocationIds;
282
    }
283
284
    /**
285
     * Moves the user group to another parent.
286
     *
287
     * @param \eZ\Publish\API\Repository\Values\User\UserGroup $userGroup
288
     * @param \eZ\Publish\API\Repository\Values\User\UserGroup $newParent
289
     *
290
     * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException if the authenticated user is not allowed to move the user group
291
     */
292
    public function moveUserGroup(APIUserGroup $userGroup, APIUserGroup $newParent)
293
    {
294
        $loadedUserGroup = $this->loadUserGroup($userGroup->id);
295
        $loadedNewParent = $this->loadUserGroup($newParent->id);
296
297
        $locationService = $this->repository->getLocationService();
298
299
        if ($loadedUserGroup->getVersionInfo()->getContentInfo()->mainLocationId === null) {
300
            throw new BadStateException('userGroup', 'existing user group is not stored and/or does not have any location yet');
301
        }
302
303
        if ($loadedNewParent->getVersionInfo()->getContentInfo()->mainLocationId === null) {
304
            throw new BadStateException('newParent', 'new user group is not stored and/or does not have any location yet');
305
        }
306
307
        $userGroupMainLocation = $locationService->loadLocation(
308
            $loadedUserGroup->getVersionInfo()->getContentInfo()->mainLocationId
309
        );
310
        $newParentMainLocation = $locationService->loadLocation(
311
            $loadedNewParent->getVersionInfo()->getContentInfo()->mainLocationId
312
        );
313
314
        $this->repository->beginTransaction();
315
        try {
316
            $locationService->moveSubtree($userGroupMainLocation, $newParentMainLocation);
317
            $this->repository->commit();
318
        } catch (Exception $e) {
319
            $this->repository->rollback();
320
            throw $e;
321
        }
322
    }
323
324
    /**
325
     * Updates the group profile with fields and meta data.
326
     *
327
     * 4.x: If the versionUpdateStruct is set in $userGroupUpdateStruct, this method internally creates a content draft, updates ts with the provided data
328
     * and publishes the draft. If a draft is explicitly required, the user group can be updated via the content service methods.
329
     *
330
     * @param \eZ\Publish\API\Repository\Values\User\UserGroup $userGroup
331
     * @param \eZ\Publish\API\Repository\Values\User\UserGroupUpdateStruct $userGroupUpdateStruct
332
     *
333
     * @return \eZ\Publish\API\Repository\Values\User\UserGroup
334
     *
335
     * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException if the authenticated user is not allowed to update the user group
336
     * @throws \eZ\Publish\API\Repository\Exceptions\ContentFieldValidationException if a field in the $userGroupUpdateStruct is not valid
337
     * @throws \eZ\Publish\API\Repository\Exceptions\ContentValidationException if a required field is set empty
338
     */
339
    public function updateUserGroup(APIUserGroup $userGroup, UserGroupUpdateStruct $userGroupUpdateStruct)
340
    {
341
        if ($userGroupUpdateStruct->contentUpdateStruct === null &&
342
            $userGroupUpdateStruct->contentMetadataUpdateStruct === null) {
343
            // both update structs are empty, nothing to do
344
            return $userGroup;
345
        }
346
347
        $contentService = $this->repository->getContentService();
348
349
        $loadedUserGroup = $this->loadUserGroup($userGroup->id);
350
351
        $this->repository->beginTransaction();
352
        try {
353
            $publishedContent = $loadedUserGroup;
354
            if ($userGroupUpdateStruct->contentUpdateStruct !== null) {
355
                $contentDraft = $contentService->createContentDraft($loadedUserGroup->getVersionInfo()->getContentInfo());
356
357
                $contentDraft = $contentService->updateContent(
358
                    $contentDraft->getVersionInfo(),
359
                    $userGroupUpdateStruct->contentUpdateStruct
360
                );
361
362
                $publishedContent = $contentService->publishVersion($contentDraft->getVersionInfo());
363
            }
364
365
            if ($userGroupUpdateStruct->contentMetadataUpdateStruct !== null) {
366
                $publishedContent = $contentService->updateContentMetadata(
367
                    $publishedContent->getVersionInfo()->getContentInfo(),
368
                    $userGroupUpdateStruct->contentMetadataUpdateStruct
369
                );
370
            }
371
372
            $this->repository->commit();
373
        } catch (Exception $e) {
374
            $this->repository->rollback();
375
            throw $e;
376
        }
377
378
        return $this->buildDomainUserGroupObject($publishedContent);
379
    }
380
381
    /**
382
     * Create a new user. The created user is published by this method.
383
     *
384
     * @param \eZ\Publish\API\Repository\Values\User\UserCreateStruct $userCreateStruct the data used for creating the user
385
     * @param \eZ\Publish\API\Repository\Values\User\UserGroup[] $parentGroups the groups which are assigned to the user after creation
386
     *
387
     * @return \eZ\Publish\API\Repository\Values\User\User
388
     *
389
     * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException if the authenticated user is not allowed to move the user group
390
     * @throws \eZ\Publish\API\Repository\Exceptions\ContentFieldValidationException if a field in the $userCreateStruct is not valid
391
     * @throws \eZ\Publish\API\Repository\Exceptions\ContentValidationException if a required field is missing or set to an empty value
392
     * @throws \eZ\Publish\API\Repository\Exceptions\InvalidArgumentException if a user with provided login already exists
393
     */
394
    public function createUser(APIUserCreateStruct $userCreateStruct, array $parentGroups)
395
    {
396
        if (empty($parentGroups)) {
397
            throw new InvalidArgumentValue('parentGroups', $parentGroups);
398
        }
399
400
        if (!is_string($userCreateStruct->login) || empty($userCreateStruct->login)) {
401
            throw new InvalidArgumentValue('login', $userCreateStruct->login, 'UserCreateStruct');
402
        }
403
404
        if (!is_string($userCreateStruct->email) || empty($userCreateStruct->email)) {
405
            throw new InvalidArgumentValue('email', $userCreateStruct->email, 'UserCreateStruct');
406
        }
407
408
        if (!preg_match('/^.+@.+\..+$/', $userCreateStruct->email)) {
409
            throw new InvalidArgumentValue('email', $userCreateStruct->email, 'UserCreateStruct');
410
        }
411
412
        if (!is_string($userCreateStruct->password) || empty($userCreateStruct->password)) {
413
            throw new InvalidArgumentValue('password', $userCreateStruct->password, 'UserCreateStruct');
414
        }
415
416
        if (!is_bool($userCreateStruct->enabled)) {
417
            throw new InvalidArgumentValue('enabled', $userCreateStruct->enabled, 'UserCreateStruct');
418
        }
419
420
        try {
421
            $this->userHandler->loadByLogin($userCreateStruct->login);
422
            throw new InvalidArgumentException('userCreateStruct', 'User with provided login already exists');
423
        } catch (NotFoundException $e) {
424
            // Do nothing
425
        }
426
427
        $contentService = $this->repository->getContentService();
428
        $locationService = $this->repository->getLocationService();
429
        $contentTypeService = $this->repository->getContentTypeService();
430
431
        if ($userCreateStruct->contentType === null) {
432
            $userCreateStruct->contentType = $contentTypeService->loadContentType($this->settings['userClassID']);
433
        }
434
435
        $errors = $this->validatePassword($userCreateStruct->password, new PasswordValidationContext([
436
            'contentType' => $userCreateStruct->contentType,
437
        ]));
438
        if (!empty($errors)) {
439
            throw new UserPasswordValidationException('password', $errors);
440
        }
441
442
        $locationCreateStructs = [];
443
        foreach ($parentGroups as $parentGroup) {
444
            $parentGroup = $this->loadUserGroup($parentGroup->id);
445
            if ($parentGroup->getVersionInfo()->getContentInfo()->mainLocationId !== null) {
446
                $locationCreateStructs[] = $locationService->newLocationCreateStruct(
447
                    $parentGroup->getVersionInfo()->getContentInfo()->mainLocationId
448
                );
449
            }
450
        }
451
452
        // Search for the first ezuser field type in content type
453
        $userFieldDefinition = null;
454
        foreach ($userCreateStruct->contentType->getFieldDefinitions() as $fieldDefinition) {
455
            if ($fieldDefinition->fieldTypeIdentifier == 'ezuser') {
456
                $userFieldDefinition = $fieldDefinition;
457
                break;
458
            }
459
        }
460
461
        if ($userFieldDefinition === null) {
462
            throw new ContentValidationException('Provided content type does not contain ezuser field type');
463
        }
464
465
        $fixUserFieldType = true;
466
        foreach ($userCreateStruct->fields as $index => $field) {
467
            if ($field->fieldDefIdentifier == $userFieldDefinition->identifier) {
468
                if ($field->value instanceof UserValue) {
469
                    $userCreateStruct->fields[$index]->value->login = $userCreateStruct->login;
470
                } else {
471
                    $userCreateStruct->fields[$index]->value = new UserValue(
472
                        [
473
                            'login' => $userCreateStruct->login,
474
                        ]
475
                    );
476
                }
477
478
                $fixUserFieldType = false;
479
            }
480
        }
481
482
        if ($fixUserFieldType) {
483
            $userCreateStruct->setField(
484
                $userFieldDefinition->identifier,
485
                new UserValue(
486
                    [
487
                        'login' => $userCreateStruct->login,
488
                    ]
489
                )
490
            );
491
        }
492
493
        $this->repository->beginTransaction();
494
        try {
495
            $contentDraft = $contentService->createContent($userCreateStruct, $locationCreateStructs);
496
            // Create user before publishing, so that external data can be returned
497
            $spiUser = $this->userHandler->create(
498
                new SPIUser(
499
                    [
500
                        'id' => $contentDraft->id,
501
                        'login' => $userCreateStruct->login,
502
                        'email' => $userCreateStruct->email,
503
                        'passwordHash' => $this->createPasswordHash(
504
                            $userCreateStruct->login,
505
                            $userCreateStruct->password,
506
                            $this->settings['siteName'],
507
                            $this->settings['hashType']
508
                        ),
509
                        'hashAlgorithm' => $this->settings['hashType'],
510
                        'passwordUpdatedAt' => time(),
511
                        'isEnabled' => $userCreateStruct->enabled,
512
                        'maxLogin' => 0,
513
                    ]
514
                )
515
            );
516
            $publishedContent = $contentService->publishVersion($contentDraft->getVersionInfo());
517
518
            $this->repository->commit();
519
        } catch (Exception $e) {
520
            $this->repository->rollback();
521
            throw $e;
522
        }
523
524
        return $this->buildDomainUserObject($spiUser, $publishedContent);
525
    }
526
527
    /**
528
     * Loads a user.
529
     *
530
     * @param mixed $userId
531
     * @param string[] $prioritizedLanguages Used as prioritized language code on translated properties of returned object.
532
     *
533
     * @return \eZ\Publish\API\Repository\Values\User\User
534
     *
535
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException if a user with the given id was not found
536
     */
537
    public function loadUser($userId, array $prioritizedLanguages = [])
538
    {
539
        /** @var \eZ\Publish\API\Repository\Values\Content\Content $content */
540
        $content = $this->repository->getContentService()->internalLoadContent($userId, $prioritizedLanguages);
0 ignored issues
show
Bug introduced by
The method internalLoadContent() does not exist on eZ\Publish\API\Repository\ContentService. Did you maybe mean loadContent()?

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...
541
        // Get spiUser value from Field Value
542
        foreach ($content->getFields() as $field) {
543
            if (!$field->value instanceof UserValue) {
544
                continue;
545
            }
546
547
            /** @var \eZ\Publish\Core\FieldType\User\Value $value */
548
            $value = $field->value;
549
            $spiUser = new SPIUser();
550
            $spiUser->id = $value->contentId;
551
            $spiUser->login = $value->login;
552
            $spiUser->email = $value->email;
553
            $spiUser->hashAlgorithm = $value->passwordHashType;
554
            $spiUser->passwordHash = $value->passwordHash;
555
            $spiUser->passwordUpdatedAt = $value->passwordUpdatedAt ? $value->passwordUpdatedAt->getTimestamp() : null;
556
            $spiUser->isEnabled = $value->enabled;
557
            $spiUser->maxLogin = $value->maxLogin;
558
            break;
559
        }
560
561
        // If for some reason not found, load it
562
        if (!isset($spiUser)) {
563
            $spiUser = $this->userHandler->load($userId);
564
        }
565
566
        return $this->buildDomainUserObject($spiUser, $content);
567
    }
568
569
    /**
570
     * Loads a user for the given login and password.
571
     *
572
     * If the password hash type differs from that configured for the service, it will be updated to the configured one.
573
     *
574
     * {@inheritdoc}
575
     *
576
     * @param string $login
577
     * @param string $password the plain password
578
     * @param string[] $prioritizedLanguages Used as prioritized language code on translated properties of returned object.
579
     *
580
     * @return \eZ\Publish\API\Repository\Values\User\User
581
     *
582
     * @throws \eZ\Publish\API\Repository\Exceptions\InvalidArgumentException if credentials are invalid
583
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException if a user with the given credentials was not found
584
     */
585
    public function loadUserByCredentials($login, $password, array $prioritizedLanguages = [])
586
    {
587
        if (!is_string($login) || empty($login)) {
588
            throw new InvalidArgumentValue('login', $login);
589
        }
590
591
        if (!is_string($password)) {
592
            throw new InvalidArgumentValue('password', $password);
593
        }
594
595
        $spiUser = $this->userHandler->loadByLogin($login);
596
        if (!$this->verifyPassword($login, $password, $spiUser)) {
597
            throw new NotFoundException('user', $login);
598
        }
599
600
        // Don't catch BadStateException, on purpose, to avoid broken hashes.
601
        $this->updatePasswordHash($login, $password, $spiUser);
602
603
        return $this->buildDomainUserObject($spiUser, null, $prioritizedLanguages);
604
    }
605
606
    /**
607
     * Update password hash to the type configured for the service, if they differ.
608
     *
609
     * @param string $login User login
610
     * @param string $password User password
611
     * @param \eZ\Publish\SPI\Persistence\User $spiUser
612
     *
613
     * @throws \eZ\Publish\Core\Base\Exceptions\BadStateException if the password is not correctly saved, in which case the update is reverted
614
     */
615
    private function updatePasswordHash($login, $password, SPIUser $spiUser)
616
    {
617
        if ($spiUser->hashAlgorithm === $this->settings['hashType']) {
618
            return;
619
        }
620
621
        $spiUser->passwordHash = $this->createPasswordHash($login, $password, null, $this->settings['hashType']);
622
        $spiUser->hashAlgorithm = $this->settings['hashType'];
623
624
        $this->repository->beginTransaction();
625
        $this->userHandler->update($spiUser);
626
        $reloadedSpiUser = $this->userHandler->load($spiUser->id);
627
628
        if ($reloadedSpiUser->passwordHash === $spiUser->passwordHash) {
629
            $this->repository->commit();
630
        } else {
631
            // Password hash was not correctly saved, possible cause: EZP-28692
632
            $this->repository->rollback();
633
            if (isset($this->logger)) {
634
                $this->logger->critical('Password hash could not be updated. Please verify that your database schema is up to date.');
635
            }
636
637
            throw new BadStateException(
638
                'user',
639
                'Could not save updated password hash, reverting to previous hash. Please verify that your database schema is up to date.'
640
            );
641
        }
642
    }
643
644
    /**
645
     * Loads a user for the given login.
646
     *
647
     * {@inheritdoc}
648
     *
649
     * @param string $login
650
     * @param string[] $prioritizedLanguages Used as prioritized language code on translated properties of returned object.
651
     *
652
     * @return \eZ\Publish\API\Repository\Values\User\User
653
     *
654
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException if a user with the given credentials was not found
655
     */
656
    public function loadUserByLogin($login, array $prioritizedLanguages = [])
657
    {
658
        if (!is_string($login) || empty($login)) {
659
            throw new InvalidArgumentValue('login', $login);
660
        }
661
662
        $spiUser = $this->userHandler->loadByLogin($login);
663
664
        return $this->buildDomainUserObject($spiUser, null, $prioritizedLanguages);
665
    }
666
667
    /**
668
     * Loads a user for the given email.
669
     *
670
     * {@inheritdoc}
671
     *
672
     * @param string $email
673
     * @param string[] $prioritizedLanguages Used as prioritized language code on translated properties of returned object.
674
     *
675
     * @return \eZ\Publish\API\Repository\Values\User\User[]
676
     */
677
    public function loadUsersByEmail($email, array $prioritizedLanguages = [])
678
    {
679
        if (!is_string($email) || empty($email)) {
680
            throw new InvalidArgumentValue('email', $email);
681
        }
682
683
        $users = [];
684
        foreach ($this->userHandler->loadByEmail($email) as $spiUser) {
685
            $users[] = $this->buildDomainUserObject($spiUser, null, $prioritizedLanguages);
686
        }
687
688
        return $users;
689
    }
690
691
    /**
692
     * Loads a user for the given token.
693
     *
694
     * {@inheritdoc}
695
     *
696
     * @param string $hash
697
     * @param string[] $prioritizedLanguages Used as prioritized language code on translated properties of returned object.
698
     *
699
     * @return \eZ\Publish\API\Repository\Values\User\User
700
     *
701
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
702
     * @throws \eZ\Publish\Core\Base\Exceptions\InvalidArgumentValue
703
     */
704
    public function loadUserByToken($hash, array $prioritizedLanguages = [])
705
    {
706
        if (!is_string($hash) || empty($hash)) {
707
            throw new InvalidArgumentValue('hash', $hash);
708
        }
709
710
        $spiUser = $this->userHandler->loadUserByToken($hash);
711
712
        return $this->buildDomainUserObject($spiUser, null, $prioritizedLanguages);
713
    }
714
715
    /**
716
     * This method deletes a user.
717
     *
718
     * @param \eZ\Publish\API\Repository\Values\User\User $user
719
     *
720
     * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException if the authenticated user is not allowed to delete the user
721
     */
722
    public function deleteUser(APIUser $user)
723
    {
724
        $loadedUser = $this->loadUser($user->id);
725
726
        $this->repository->beginTransaction();
727
        try {
728
            $affectedLocationIds = $this->repository->getContentService()->deleteContent($loadedUser->getVersionInfo()->getContentInfo());
729
            $this->userHandler->delete($loadedUser->id);
730
            $this->repository->commit();
731
        } catch (Exception $e) {
732
            $this->repository->rollback();
733
            throw $e;
734
        }
735
736
        return $affectedLocationIds;
737
    }
738
739
    /**
740
     * Updates a user.
741
     *
742
     * 4.x: If the versionUpdateStruct is set in the user update structure, this method internally creates a content draft, updates ts with the provided data
743
     * and publishes the draft. If a draft is explicitly required, the user group can be updated via the content service methods.
744
     *
745
     * @param \eZ\Publish\API\Repository\Values\User\User $user
746
     * @param \eZ\Publish\API\Repository\Values\User\UserUpdateStruct $userUpdateStruct
747
     *
748
     * @throws \eZ\Publish\API\Repository\Exceptions\ContentFieldValidationException if a field in the $userUpdateStruct is not valid
749
     * @throws \eZ\Publish\API\Repository\Exceptions\ContentValidationException if a required field is set empty
750
     * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException if the authenticated user is not allowed to update the user
751
     *
752
     * @return \eZ\Publish\API\Repository\Values\User\User
753
     */
754
    public function updateUser(APIUser $user, UserUpdateStruct $userUpdateStruct)
755
    {
756
        $loadedUser = $this->loadUser($user->id);
757
758
        // We need to determine if we have anything to update.
759
        // UserUpdateStruct is specific as some of the new content is in
760
        // content update struct and some of it is in additional fields like
761
        // email, password and so on
762
        $doUpdate = false;
763
        foreach ($userUpdateStruct as $propertyValue) {
0 ignored issues
show
Bug introduced by
The expression $userUpdateStruct of type object<eZ\Publish\API\Re...\User\UserUpdateStruct> is not traversable.
Loading history...
764
            if ($propertyValue !== null) {
765
                $doUpdate = true;
766
                break;
767
            }
768
        }
769
770
        if (!$doUpdate) {
771
            // Nothing to update, so we just quit
772
            return $user;
773
        }
774
775
        if ($userUpdateStruct->email !== null) {
776
            if (!is_string($userUpdateStruct->email) || empty($userUpdateStruct->email)) {
777
                throw new InvalidArgumentValue('email', $userUpdateStruct->email, 'UserUpdateStruct');
778
            }
779
780
            if (!preg_match('/^.+@.+\..+$/', $userUpdateStruct->email)) {
781
                throw new InvalidArgumentValue('email', $userUpdateStruct->email, 'UserUpdateStruct');
782
            }
783
        }
784
785
        if ($userUpdateStruct->enabled !== null && !is_bool($userUpdateStruct->enabled)) {
786
            throw new InvalidArgumentValue('enabled', $userUpdateStruct->enabled, 'UserUpdateStruct');
787
        }
788
789
        if ($userUpdateStruct->maxLogin !== null && !is_int($userUpdateStruct->maxLogin)) {
790
            throw new InvalidArgumentValue('maxLogin', $userUpdateStruct->maxLogin, 'UserUpdateStruct');
791
        }
792
793
        if ($userUpdateStruct->password !== null) {
794
            if (!is_string($userUpdateStruct->password) || empty($userUpdateStruct->password)) {
795
                throw new InvalidArgumentValue('password', $userUpdateStruct->password, 'UserUpdateStruct');
796
            }
797
798
            $userContentType = $this->repository->getContentTypeService()->loadContentType(
799
                $user->contentInfo->contentTypeId
800
            );
801
802
            $errors = $this->validatePassword($userUpdateStruct->password, new PasswordValidationContext([
803
                'contentType' => $userContentType,
804
                'user' => $user,
805
            ]));
806
807
            if (!empty($errors)) {
808
                throw new UserPasswordValidationException('password', $errors);
809
            }
810
        }
811
812
        $contentService = $this->repository->getContentService();
813
814
        $canEditContent = $this->permissionResolver->canUser('content', 'edit', $loadedUser);
815
816
        if (!$canEditContent && $this->isUserProfileUpdateRequested($userUpdateStruct)) {
817
            throw new UnauthorizedException('content', 'edit');
818
        }
819
820
        if (!empty($userUpdateStruct->password) &&
821
            !$canEditContent &&
822
            !$this->permissionResolver->canUser('user', 'password', $loadedUser)
823
        ) {
824
            throw new UnauthorizedException('user', 'password');
825
        }
826
827
        $this->repository->beginTransaction();
828
        try {
829
            $publishedContent = $loadedUser;
830
            if ($userUpdateStruct->contentUpdateStruct !== null) {
831
                $contentDraft = $contentService->createContentDraft($loadedUser->getVersionInfo()->getContentInfo());
832
                $contentDraft = $contentService->updateContent(
833
                    $contentDraft->getVersionInfo(),
834
                    $userUpdateStruct->contentUpdateStruct
835
                );
836
                $publishedContent = $contentService->publishVersion($contentDraft->getVersionInfo());
837
            }
838
839
            if ($userUpdateStruct->contentMetadataUpdateStruct !== null) {
840
                $contentService->updateContentMetadata(
841
                    $publishedContent->getVersionInfo()->getContentInfo(),
842
                    $userUpdateStruct->contentMetadataUpdateStruct
843
                );
844
            }
845
846
            $spiUser = new SPIUser([
847
                'id' => $loadedUser->id,
848
                'login' => $loadedUser->login,
849
                'email' => $userUpdateStruct->email ?: $loadedUser->email,
850
                'isEnabled' => $userUpdateStruct->enabled !== null ? $userUpdateStruct->enabled : $loadedUser->enabled,
851
                'maxLogin' => $userUpdateStruct->maxLogin !== null ? (int)$userUpdateStruct->maxLogin : $loadedUser->maxLogin,
852
            ]);
853
854
            if ($userUpdateStruct->password) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $userUpdateStruct->password 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...
855
                $spiUser->passwordHash = $this->createPasswordHash(
856
                    $loadedUser->login,
857
                    $userUpdateStruct->password,
858
                    $this->settings['siteName'],
859
                    $this->settings['hashType']
860
                );
861
                $spiUser->hashAlgorithm = $this->settings['hashType'];
862
                $spiUser->passwordUpdatedAt = time();
863
            } else {
864
                $spiUser->passwordHash = $loadedUser->passwordHash;
865
                $spiUser->hashAlgorithm = $loadedUser->hashAlgorithm;
866
                $spiUser->passwordUpdatedAt = $loadedUser->passwordUpdatedAt ? $loadedUser->passwordUpdatedAt->getTimestamp() : null;
867
            }
868
869
            $this->userHandler->update($spiUser);
870
871
            $this->repository->commit();
872
        } catch (Exception $e) {
873
            $this->repository->rollback();
874
            throw $e;
875
        }
876
877
        return $this->loadUser($loadedUser->id);
878
    }
879
880
    /**
881
     * Update the user token information specified by the user token struct.
882
     *
883
     * @param \eZ\Publish\API\Repository\Values\User\User $user
884
     * @param \eZ\Publish\API\Repository\Values\User\UserTokenUpdateStruct $userTokenUpdateStruct
885
     *
886
     * @throws \eZ\Publish\Core\Base\Exceptions\InvalidArgumentValue
887
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
888
     * @throws \RuntimeException
889
     * @throws \Exception
890
     *
891
     * @return \eZ\Publish\API\Repository\Values\User\User
892
     */
893
    public function updateUserToken(APIUser $user, UserTokenUpdateStruct $userTokenUpdateStruct)
894
    {
895
        $loadedUser = $this->loadUser($user->id);
896
897
        if ($userTokenUpdateStruct->hashKey !== null && (!is_string($userTokenUpdateStruct->hashKey) || empty($userTokenUpdateStruct->hashKey))) {
898
            throw new InvalidArgumentValue('hashKey', $userTokenUpdateStruct->hashKey, 'UserTokenUpdateStruct');
899
        }
900
901
        if ($userTokenUpdateStruct->time === null) {
902
            throw new InvalidArgumentValue('time', $userTokenUpdateStruct->time, 'UserTokenUpdateStruct');
903
        }
904
905
        $this->repository->beginTransaction();
906
        try {
907
            $this->userHandler->updateUserToken(
908
                new SPIUserTokenUpdateStruct(
909
                    [
910
                        'userId' => $loadedUser->id,
911
                        'hashKey' => $userTokenUpdateStruct->hashKey,
912
                        'time' => $userTokenUpdateStruct->time->getTimestamp(),
913
                    ]
914
                )
915
            );
916
            $this->repository->commit();
917
        } catch (Exception $e) {
918
            $this->repository->rollback();
919
            throw $e;
920
        }
921
922
        return $this->loadUser($loadedUser->id);
923
    }
924
925
    /**
926
     * Expires user token with user hash.
927
     *
928
     * @param string $hash
929
     */
930
    public function expireUserToken($hash)
931
    {
932
        $this->repository->beginTransaction();
933
        try {
934
            $this->userHandler->expireUserToken($hash);
935
            $this->repository->commit();
936
        } catch (Exception $e) {
937
            $this->repository->rollback();
938
            throw $e;
939
        }
940
    }
941
942
    /**
943
     * Assigns a new user group to the user.
944
     *
945
     * @param \eZ\Publish\API\Repository\Values\User\User $user
946
     * @param \eZ\Publish\API\Repository\Values\User\UserGroup $userGroup
947
     *
948
     * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException if the authenticated user is not allowed to assign the user group to the user
949
     * @throws \eZ\Publish\API\Repository\Exceptions\InvalidArgumentException if the user is already in the given user group
950
     */
951
    public function assignUserToUserGroup(APIUser $user, APIUserGroup $userGroup)
952
    {
953
        $loadedUser = $this->loadUser($user->id);
954
        $loadedGroup = $this->loadUserGroup($userGroup->id);
955
        $locationService = $this->repository->getLocationService();
956
957
        $existingGroupIds = [];
958
        $userLocations = $locationService->loadLocations($loadedUser->getVersionInfo()->getContentInfo());
959
        foreach ($userLocations as $userLocation) {
960
            $existingGroupIds[] = $userLocation->parentLocationId;
961
        }
962
963
        if ($loadedGroup->getVersionInfo()->getContentInfo()->mainLocationId === null) {
964
            throw new BadStateException('userGroup', 'user group has no main location or no locations');
965
        }
966
967
        if (in_array($loadedGroup->getVersionInfo()->getContentInfo()->mainLocationId, $existingGroupIds)) {
968
            // user is already assigned to the user group
969
            throw new InvalidArgumentException('user', 'user is already in the given user group');
970
        }
971
972
        $locationCreateStruct = $locationService->newLocationCreateStruct(
973
            $loadedGroup->getVersionInfo()->getContentInfo()->mainLocationId
974
        );
975
976
        $this->repository->beginTransaction();
977
        try {
978
            $locationService->createLocation(
979
                $loadedUser->getVersionInfo()->getContentInfo(),
980
                $locationCreateStruct
981
            );
982
            $this->repository->commit();
983
        } catch (Exception $e) {
984
            $this->repository->rollback();
985
            throw $e;
986
        }
987
    }
988
989
    /**
990
     * Removes a user group from the user.
991
     *
992
     * @param \eZ\Publish\API\Repository\Values\User\User $user
993
     * @param \eZ\Publish\API\Repository\Values\User\UserGroup $userGroup
994
     *
995
     * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException if the authenticated user is not allowed to remove the user group from the user
996
     * @throws \eZ\Publish\API\Repository\Exceptions\InvalidArgumentException if the user is not in the given user group
997
     * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException If $userGroup is the last assigned user group
998
     */
999
    public function unAssignUserFromUserGroup(APIUser $user, APIUserGroup $userGroup)
1000
    {
1001
        $loadedUser = $this->loadUser($user->id);
1002
        $loadedGroup = $this->loadUserGroup($userGroup->id);
1003
        $locationService = $this->repository->getLocationService();
1004
1005
        $userLocations = $locationService->loadLocations($loadedUser->getVersionInfo()->getContentInfo());
1006
        if (empty($userLocations)) {
1007
            throw new BadStateException('user', 'user has no locations, cannot unassign from group');
1008
        }
1009
1010
        if ($loadedGroup->getVersionInfo()->getContentInfo()->mainLocationId === null) {
1011
            throw new BadStateException('userGroup', 'user group has no main location or no locations, cannot unassign');
1012
        }
1013
1014
        foreach ($userLocations as $userLocation) {
1015
            if ($userLocation->parentLocationId == $loadedGroup->getVersionInfo()->getContentInfo()->mainLocationId) {
1016
                // Throw this specific BadState when we know argument is valid
1017
                if (count($userLocations) === 1) {
1018
                    throw new BadStateException('user', 'user only has one user group, cannot unassign from last group');
1019
                }
1020
1021
                $this->repository->beginTransaction();
1022
                try {
1023
                    $locationService->deleteLocation($userLocation);
1024
                    $this->repository->commit();
1025
1026
                    return;
1027
                } catch (Exception $e) {
1028
                    $this->repository->rollback();
1029
                    throw $e;
1030
                }
1031
            }
1032
        }
1033
1034
        throw new InvalidArgumentException('userGroup', 'user is not in the given user group');
1035
    }
1036
1037
    /**
1038
     * Loads the user groups the user belongs to.
1039
     *
1040
     * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException if the authenticated user is not allowed read the user or user group
1041
     *
1042
     * @param \eZ\Publish\API\Repository\Values\User\User $user
1043
     * @param int $offset the start offset for paging
1044
     * @param int $limit the number of user groups returned
1045
     * @param string[] $prioritizedLanguages Used as prioritized language code on translated properties of returned object.
1046
     *
1047
     * @return \eZ\Publish\API\Repository\Values\User\UserGroup[]
1048
     */
1049
    public function loadUserGroupsOfUser(APIUser $user, $offset = 0, $limit = 25, array $prioritizedLanguages = [])
1050
    {
1051
        $locationService = $this->repository->getLocationService();
1052
1053
        if (!$this->repository->getPermissionResolver()->canUser('content', 'read', $user)) {
1054
            throw new UnauthorizedException('content', 'read');
1055
        }
1056
1057
        $userLocations = $locationService->loadLocations(
1058
            $user->getVersionInfo()->getContentInfo()
1059
        );
1060
1061
        $parentLocationIds = [];
1062
        foreach ($userLocations as $userLocation) {
1063
            if ($userLocation->parentLocationId !== null) {
1064
                $parentLocationIds[] = $userLocation->parentLocationId;
1065
            }
1066
        }
1067
1068
        $searchQuery = new LocationQuery();
1069
1070
        $searchQuery->offset = $offset;
1071
        $searchQuery->limit = $limit;
1072
        $searchQuery->performCount = false;
1073
1074
        $searchQuery->filter = new CriterionLogicalAnd(
1075
            [
1076
                new CriterionContentTypeId($this->settings['userGroupClassID']),
1077
                new CriterionLocationId($parentLocationIds),
1078
            ]
1079
        );
1080
1081
        $searchResult = $this->repository->getSearchService()->findLocations($searchQuery);
1082
1083
        $userGroups = [];
1084
        foreach ($searchResult->searchHits as $resultItem) {
1085
            $userGroups[] = $this->buildDomainUserGroupObject(
1086
                $this->repository->getContentService()->internalLoadContent(
0 ignored issues
show
Bug introduced by
The method internalLoadContent() does not exist on eZ\Publish\API\Repository\ContentService. Did you maybe mean loadContent()?

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...
1087
                    $resultItem->valueObject->contentInfo->id,
0 ignored issues
show
Documentation introduced by
The property contentInfo does not exist on object<eZ\Publish\API\Re...ory\Values\ValueObject>. Since you implemented __get, maybe consider adding a @property annotation.

Since your code implements the magic getter _get, this function will be called for any read access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

If the property has read access only, you can use the @property-read annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
1088
                    $prioritizedLanguages
1089
                )
1090
            );
1091
        }
1092
1093
        return $userGroups;
1094
    }
1095
1096
    /**
1097
     * Loads the users of a user group.
1098
     *
1099
     * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException if the authenticated user is not allowed to read the users or user group
1100
     *
1101
     * @param \eZ\Publish\API\Repository\Values\User\UserGroup $userGroup
1102
     * @param int $offset the start offset for paging
1103
     * @param int $limit the number of users returned
1104
     * @param string[] $prioritizedLanguages Used as prioritized language code on translated properties of returned object.
1105
     *
1106
     * @return \eZ\Publish\API\Repository\Values\User\User[]
1107
     */
1108
    public function loadUsersOfUserGroup(
1109
        APIUserGroup $userGroup,
1110
        $offset = 0,
1111
        $limit = 25,
1112
        array $prioritizedLanguages = []
1113
    ) {
1114
        $loadedUserGroup = $this->loadUserGroup($userGroup->id);
1115
1116
        if ($loadedUserGroup->getVersionInfo()->getContentInfo()->mainLocationId === null) {
1117
            return [];
1118
        }
1119
1120
        $mainGroupLocation = $this->repository->getLocationService()->loadLocation(
1121
            $loadedUserGroup->getVersionInfo()->getContentInfo()->mainLocationId
1122
        );
1123
1124
        $searchQuery = new LocationQuery();
1125
1126
        $searchQuery->filter = new CriterionLogicalAnd(
1127
            [
1128
                new CriterionContentTypeId($this->settings['userClassID']),
1129
                new CriterionParentLocationId($mainGroupLocation->id),
1130
            ]
1131
        );
1132
1133
        $searchQuery->offset = $offset;
1134
        $searchQuery->limit = $limit;
1135
        $searchQuery->performCount = false;
1136
        $searchQuery->sortClauses = $mainGroupLocation->getSortClauses();
0 ignored issues
show
Documentation Bug introduced by
It seems like $mainGroupLocation->getSortClauses() of type array<integer,object,{"0":"object"}> is incompatible with the declared type array<integer,object<eZ\...tent\Query\SortClause>> of property $sortClauses.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
1137
1138
        $searchResult = $this->repository->getSearchService()->findLocations($searchQuery);
1139
1140
        $users = [];
1141
        foreach ($searchResult->searchHits as $resultItem) {
1142
            $users[] = $this->buildDomainUserObject(
1143
                $this->userHandler->load($resultItem->valueObject->contentInfo->id),
0 ignored issues
show
Documentation introduced by
The property contentInfo does not exist on object<eZ\Publish\API\Re...ory\Values\ValueObject>. Since you implemented __get, maybe consider adding a @property annotation.

Since your code implements the magic getter _get, this function will be called for any read access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

If the property has read access only, you can use the @property-read annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
1144
                $this->repository->getContentService()->internalLoadContent(
0 ignored issues
show
Bug introduced by
The method internalLoadContent() does not exist on eZ\Publish\API\Repository\ContentService. Did you maybe mean loadContent()?

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...
1145
                    $resultItem->valueObject->contentInfo->id,
0 ignored issues
show
Documentation introduced by
The property contentInfo does not exist on object<eZ\Publish\API\Re...ory\Values\ValueObject>. Since you implemented __get, maybe consider adding a @property annotation.

Since your code implements the magic getter _get, this function will be called for any read access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

If the property has read access only, you can use the @property-read annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
1146
                    $prioritizedLanguages
1147
                )
1148
            );
1149
        }
1150
1151
        return $users;
1152
    }
1153
1154
    /**
1155
     * {@inheritdoc}
1156
     */
1157
    public function isUser(APIContent $content): bool
1158
    {
1159
        // First check against config for fast check
1160
        if ($this->settings['userClassID'] == $content->getVersionInfo()->getContentInfo()->contentTypeId) {
1161
            return true;
1162
        }
1163
1164
        // For users we ultimately need to look for ezuser type as content type id could be several for users.
1165
        // And config might be different from one SA to the next, which we don't care about here.
1166
        foreach ($content->getFields() as $field) {
1167
            if ($field->fieldTypeIdentifier === 'ezuser') {
1168
                return true;
1169
            }
1170
        }
1171
1172
        return false;
1173
    }
1174
1175
    /**
1176
     * {@inheritdoc}
1177
     */
1178
    public function isUserGroup(APIContent $content): bool
1179
    {
1180
        return $this->settings['userGroupClassID'] == $content->getVersionInfo()->getContentInfo()->contentTypeId;
1181
    }
1182
1183
    /**
1184
     * Instantiate a user create class.
1185
     *
1186
     * @param string $login the login of the new user
1187
     * @param string $email the email of the new user
1188
     * @param string $password the plain password of the new user
1189
     * @param string $mainLanguageCode the main language for the underlying content object
1190
     * @param \eZ\Publish\API\Repository\Values\ContentType\ContentType $contentType 5.x the content type for the underlying content object. In 4.x it is ignored and taken from the configuration
1191
     *
1192
     * @return \eZ\Publish\API\Repository\Values\User\UserCreateStruct
1193
     */
1194
    public function newUserCreateStruct($login, $email, $password, $mainLanguageCode, $contentType = null)
1195
    {
1196
        if ($contentType === null) {
1197
            $contentType = $this->repository->getContentTypeService()->loadContentType(
1198
                $this->settings['userClassID']
1199
            );
1200
        }
1201
1202
        return new UserCreateStruct(
1203
            [
1204
                'contentType' => $contentType,
1205
                'mainLanguageCode' => $mainLanguageCode,
1206
                'login' => $login,
1207
                'email' => $email,
1208
                'password' => $password,
1209
                'enabled' => true,
1210
                'fields' => [],
1211
            ]
1212
        );
1213
    }
1214
1215
    /**
1216
     * Instantiate a user group create class.
1217
     *
1218
     * @param string $mainLanguageCode The main language for the underlying content object
1219
     * @param null|\eZ\Publish\API\Repository\Values\ContentType\ContentType $contentType 5.x the content type for the underlying content object. In 4.x it is ignored and taken from the configuration
1220
     *
1221
     * @return \eZ\Publish\API\Repository\Values\User\UserGroupCreateStruct
1222
     */
1223
    public function newUserGroupCreateStruct($mainLanguageCode, $contentType = null)
1224
    {
1225
        if ($contentType === null) {
1226
            $contentType = $this->repository->getContentTypeService()->loadContentType(
1227
                $this->settings['userGroupClassID']
1228
            );
1229
        }
1230
1231
        return new UserGroupCreateStruct(
1232
            [
1233
                'contentType' => $contentType,
1234
                'mainLanguageCode' => $mainLanguageCode,
1235
                'fields' => [],
1236
            ]
1237
        );
1238
    }
1239
1240
    /**
1241
     * Instantiate a new user update struct.
1242
     *
1243
     * @return \eZ\Publish\API\Repository\Values\User\UserUpdateStruct
1244
     */
1245
    public function newUserUpdateStruct()
1246
    {
1247
        return new UserUpdateStruct();
1248
    }
1249
1250
    /**
1251
     * Instantiate a new user group update struct.
1252
     *
1253
     * @return \eZ\Publish\API\Repository\Values\User\UserGroupUpdateStruct
1254
     */
1255
    public function newUserGroupUpdateStruct()
1256
    {
1257
        return new UserGroupUpdateStruct();
1258
    }
1259
1260
    /**
1261
     * {@inheritdoc}
1262
     */
1263
    public function validatePassword(string $password, PasswordValidationContext $context = null): array
1264
    {
1265
        if ($context === null) {
1266
            $contentType = $this->repository->getContentTypeService()->loadContentType(
1267
                $this->settings['userClassID']
1268
            );
1269
1270
            $context = new PasswordValidationContext([
1271
                'contentType' => $contentType,
1272
            ]);
1273
        }
1274
1275
        // Search for the first ezuser field type in content type
1276
        $userFieldDefinition = null;
1277
        foreach ($context->contentType->getFieldDefinitions() as $fieldDefinition) {
1278
            if ($fieldDefinition->fieldTypeIdentifier === 'ezuser') {
1279
                $userFieldDefinition = $fieldDefinition;
1280
                break;
1281
            }
1282
        }
1283
1284
        if ($userFieldDefinition === null) {
1285
            throw new ContentValidationException('Provided content type does not contain ezuser field type');
1286
        }
1287
1288
        $configuration = $userFieldDefinition->getValidatorConfiguration();
1289
        if (!isset($configuration['PasswordValueValidator'])) {
1290
            return [];
1291
        }
1292
1293
        return (new UserPasswordValidator($configuration['PasswordValueValidator']))->validate($password);
1294
    }
1295
1296
    /**
1297
     * Builds the domain UserGroup object from provided Content object.
1298
     *
1299
     * @param \eZ\Publish\API\Repository\Values\Content\Content $content
1300
     *
1301
     * @return \eZ\Publish\API\Repository\Values\User\UserGroup
1302
     */
1303
    protected function buildDomainUserGroupObject(APIContent $content)
1304
    {
1305
        $locationService = $this->repository->getLocationService();
1306
1307
        if ($content->getVersionInfo()->getContentInfo()->mainLocationId !== null) {
1308
            $mainLocation = $locationService->loadLocation(
1309
                $content->getVersionInfo()->getContentInfo()->mainLocationId
1310
            );
1311
            $parentLocation = $this->locationHandler->load($mainLocation->parentLocationId);
1312
        }
1313
1314
        return new UserGroup(
1315
            [
1316
                'content' => $content,
1317
                'parentId' => $parentLocation->contentId ?? null,
1318
            ]
1319
        );
1320
    }
1321
1322
    /**
1323
     * Builds the domain user object from provided persistence user object.
1324
     *
1325
     * @param \eZ\Publish\SPI\Persistence\User $spiUser
1326
     * @param \eZ\Publish\API\Repository\Values\Content\Content|null $content
1327
     * @param string[] $prioritizedLanguages Used as prioritized language code on translated properties of returned object.
1328
     *
1329
     * @return \eZ\Publish\API\Repository\Values\User\User
1330
     */
1331
    protected function buildDomainUserObject(
1332
        SPIUser $spiUser,
1333
        APIContent $content = null,
1334
        array $prioritizedLanguages = []
1335
    ) {
1336
        if ($content === null) {
1337
            $content = $this->repository->getContentService()->internalLoadContent(
0 ignored issues
show
Bug introduced by
The method internalLoadContent() does not exist on eZ\Publish\API\Repository\ContentService. Did you maybe mean loadContent()?

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...
1338
                $spiUser->id,
1339
                $prioritizedLanguages
1340
            );
1341
        }
1342
1343
        return new User(
1344
            [
1345
                'content' => $content,
1346
                'login' => $spiUser->login,
1347
                'email' => $spiUser->email,
1348
                'passwordHash' => $spiUser->passwordHash,
1349
                'passwordUpdatedAt' => $this->getDateTime($spiUser->passwordUpdatedAt),
1350
                'hashAlgorithm' => (int)$spiUser->hashAlgorithm,
1351
                'enabled' => $spiUser->isEnabled,
1352
                'maxLogin' => (int)$spiUser->maxLogin,
1353
            ]
1354
        );
1355
    }
1356
1357
    public function getPasswordInfo(APIUser $user): PasswordInfo
1358
    {
1359
        $passwordUpdatedAt = $user->passwordUpdatedAt;
1360
        if ($passwordUpdatedAt === null) {
1361
            return new PasswordInfo();
1362
        }
1363
1364
        $definition = $this->getUserFieldDefinition($user->getContentType());
1365
        if ($definition === null) {
1366
            return new PasswordInfo();
1367
        }
1368
1369
        $expirationDate = null;
1370
        $expirationWarningDate = null;
1371
1372
        $passwordTTL = (int)$definition->fieldSettings[UserType::PASSWORD_TTL_SETTING];
1373
        if ($passwordTTL > 0) {
1374
            if ($passwordUpdatedAt instanceof DateTime) {
1375
                $passwordUpdatedAt = DateTimeImmutable::createFromMutable($passwordUpdatedAt);
1376
            }
1377
1378
            $expirationDate = $passwordUpdatedAt->add(new DateInterval(sprintf('P%dD', $passwordTTL)));
1379
1380
            $passwordTTLWarning = (int)$definition->fieldSettings[UserType::PASSWORD_TTL_WARNING_SETTING];
1381
            if ($passwordTTLWarning > 0) {
1382
                $expirationWarningDate = $expirationDate->sub(new DateInterval(sprintf('P%dD', $passwordTTLWarning)));
1383
            }
1384
        }
1385
1386
        return new PasswordInfo($expirationDate, $expirationWarningDate);
1387
    }
1388
1389
    private function getUserFieldDefinition(ContentType $contentType): ?FieldDefinition
1390
    {
1391
        foreach ($contentType->getFieldDefinitions() as $fieldDefinition) {
1392
            if ($fieldDefinition->fieldTypeIdentifier == 'ezuser') {
1393
                return $fieldDefinition;
1394
            }
1395
        }
1396
1397
        return null;
1398
    }
1399
1400
    /**
1401
     * Verifies if the provided login and password are valid.
1402
     *
1403
     * @param string $login User login
1404
     * @param string $password User password
1405
     * @param \eZ\Publish\SPI\Persistence\User $spiUser Loaded user handler
1406
     *
1407
     * @return bool return true if the login and password are sucessfully
1408
     * validate and false, if not.
1409
     */
1410
    protected function verifyPassword($login, $password, $spiUser)
1411
    {
1412
        // In case of bcrypt let php's password functionality do it's magic
1413
        if ($spiUser->hashAlgorithm === APIUser::PASSWORD_HASH_BCRYPT ||
1414
            $spiUser->hashAlgorithm === APIUser::PASSWORD_HASH_PHP_DEFAULT) {
1415
            return password_verify($password, $spiUser->passwordHash);
1416
        }
1417
1418
        // Randomize login time to protect against timing attacks
1419
        usleep(random_int(0, 30000));
1420
1421
        $passwordHash = $this->createPasswordHash(
1422
            $login,
1423
            $password,
1424
            $this->settings['siteName'],
1425
            $spiUser->hashAlgorithm
1426
        );
1427
1428
        return $passwordHash === $spiUser->passwordHash;
1429
    }
1430
1431
    /**
1432
     * Returns password hash based on user data and site settings.
1433
     *
1434
     * @param string $login User login
1435
     * @param string $password User password
1436
     * @param string $site The name of the site
1437
     * @param int $type Type of password to generate
1438
     *
1439
     * @return string Generated password hash
1440
     *
1441
     * @throws \eZ\Publish\API\Repository\Exceptions\InvalidArgumentException if the type is not recognized
1442
     */
1443
    protected function createPasswordHash($login, $password, $site, $type)
1444
    {
1445
        $deprecationWarningFormat = 'Password hash type %s is deprecated since 6.13.';
1446
1447
        switch ($type) {
1448
            case APIUser::PASSWORD_HASH_MD5_PASSWORD:
0 ignored issues
show
Deprecated Code introduced by
The constant eZ\Publish\API\Repositor...SWORD_HASH_MD5_PASSWORD has been deprecated with message: since 6.13

This class constant has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the constant will be removed from the class and what other constant to use instead.

Loading history...
1449
                @trigger_error(sprintf($deprecationWarningFormat, 'PASSWORD_HASH_MD5_PASSWORD'), E_USER_DEPRECATED);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1450
1451
                return md5($password);
1452
1453
            case APIUser::PASSWORD_HASH_MD5_USER:
0 ignored issues
show
Deprecated Code introduced by
The constant eZ\Publish\API\Repositor...:PASSWORD_HASH_MD5_USER has been deprecated with message: since 6.13

This class constant has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the constant will be removed from the class and what other constant to use instead.

Loading history...
1454
                @trigger_error(sprintf($deprecationWarningFormat, 'PASSWORD_HASH_MD5_USER'), E_USER_DEPRECATED);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1455
1456
                return md5("$login\n$password");
1457
1458
            case APIUser::PASSWORD_HASH_MD5_SITE:
0 ignored issues
show
Deprecated Code introduced by
The constant eZ\Publish\API\Repositor...:PASSWORD_HASH_MD5_SITE has been deprecated with message: since 6.13

This class constant has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the constant will be removed from the class and what other constant to use instead.

Loading history...
1459
                @trigger_error(sprintf($deprecationWarningFormat, 'PASSWORD_HASH_MD5_SITE'), E_USER_DEPRECATED);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1460
1461
                return md5("$login\n$password\n$site");
1462
1463
            case APIUser::PASSWORD_HASH_PLAINTEXT:
0 ignored issues
show
Deprecated Code introduced by
The constant eZ\Publish\API\Repositor...PASSWORD_HASH_PLAINTEXT has been deprecated with message: since 6.13

This class constant has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the constant will be removed from the class and what other constant to use instead.

Loading history...
1464
                @trigger_error(sprintf($deprecationWarningFormat, 'PASSWORD_HASH_PLAINTEXT'), E_USER_DEPRECATED);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1465
1466
                return $password;
1467
1468
            case APIUser::PASSWORD_HASH_BCRYPT:
1469
                return password_hash($password, PASSWORD_BCRYPT);
1470
1471
            case APIUser::PASSWORD_HASH_PHP_DEFAULT:
1472
                return password_hash($password, PASSWORD_DEFAULT);
1473
1474
            default:
1475
                throw new InvalidArgumentException('type', "Password hash type '$type' is not recognized");
1476
        }
1477
    }
1478
1479
    /**
1480
     * Return true if any of the UserUpdateStruct properties refers to User Profile (Content) update.
1481
     *
1482
     * @param UserUpdateStruct $userUpdateStruct
1483
     *
1484
     * @return bool
1485
     */
1486
    private function isUserProfileUpdateRequested(UserUpdateStruct $userUpdateStruct)
1487
    {
1488
        return
1489
            !empty($userUpdateStruct->contentUpdateStruct) ||
1490
            !empty($userUpdateStruct->contentMetadataUpdateStruct) ||
1491
            !empty($userUpdateStruct->email) ||
1492
            !empty($userUpdateStruct->enabled) ||
1493
            !empty($userUpdateStruct->maxLogin);
1494
    }
1495
1496
    private function getDateTime(?int $timestamp): ?DateTimeInterface
1497
    {
1498
        if ($timestamp !== null) {
1499
            // Instead of using DateTime(ts) we use setTimeStamp() so timezone does not get set to UTC
1500
            $dateTime = new DateTime();
1501
            $dateTime->setTimestamp($timestamp);
1502
1503
            return DateTimeImmutable::createFromMutable($dateTime);
1504
        }
1505
1506
        return null;
1507
    }
1508
}
1509