Users   F
last analyzed

Complexity

Total Complexity 92

Size/Duplication

Total Lines 643
Duplicated Lines 0 %

Test Coverage

Coverage 94.05%

Importance

Changes 5
Bugs 2 Features 0
Metric Value
eloc 302
dl 0
loc 643
ccs 316
cts 336
cp 0.9405
rs 2
c 5
b 2
f 0
wmc 92

29 Methods

Rating   Name   Duplication   Size   Complexity  
A resetPassword() 0 3 1
A readAllActiveFromTeam() 0 4 1
A readAll() 0 8 1
B readFromQuery() 0 52 6
A __construct() 0 13 5
A readAllFromTeam() 0 3 1
B patch() 0 41 7
A sendOnboardingEmailsAfterValidation() 0 11 4
A postAction() 0 4 1
A readOneFull() 0 17 1
A validate() 0 10 1
A checkCurrentPasswordOrExplode() 0 10 3
A updatePassword() 0 28 4
B canReadOrExplode() 0 14 7
A isAdminSomewhere() 0 10 1
A canWriteOrExplode() 0 7 4
A destroy() 0 14 2
A allowUntrustedLogin() 0 13 2
A search() 0 20 4
A disable2fa() 0 8 3
B update() 0 42 10
A requireResetPassword() 0 9 2
A invalidateToken() 0 6 1
A notifyAdmins() 0 8 3
A isAdminOf() 0 22 2
A readNamesFromIds() 0 10 2
A getPage() 0 3 1
A readOne() 0 14 2
B createOne() 0 119 10

How to fix   Complexity   

Complex Class

Complex classes like Users often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Users, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * @author Nicolas CARPi <[email protected]>
5
 * @copyright 2012 Nicolas CARPi
6
 * @see https://www.elabftw.net Official website
7
 * @license AGPL-3.0
8
 * @package elabftw
9
 */
10
11
declare(strict_types=1);
12
13
namespace Elabftw\Models;
14
15
use Elabftw\AuditEvent\PasswordChanged;
16
use Elabftw\AuditEvent\UserAttributeChanged;
17
use Elabftw\AuditEvent\UserRegister;
18
use Elabftw\Auth\Local;
19
use Elabftw\Elabftw\App;
0 ignored issues
show
Bug introduced by
The type Elabftw\Elabftw\App was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
20
use Elabftw\Elabftw\Db;
21
use Elabftw\Elabftw\Tools;
0 ignored issues
show
Bug introduced by
The type Elabftw\Elabftw\Tools was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
22
use Elabftw\Elabftw\UserParams;
23
use Elabftw\Enums\Action;
24
use Elabftw\Enums\BasePermissions;
25
use Elabftw\Enums\State;
26
use Elabftw\Enums\Usergroup;
27
use Elabftw\Exceptions\IllegalActionException;
28
use Elabftw\Exceptions\ImproperActionException;
29
use Elabftw\Exceptions\InvalidCredentialsException;
30
use Elabftw\Exceptions\ResourceNotFoundException;
31
use Elabftw\Interfaces\RestInterface;
32
use Elabftw\Models\Notifications\OnboardingEmail;
33
use Elabftw\Models\Notifications\SelfIsValidated;
34
use Elabftw\Models\Notifications\SelfNeedValidation;
35
use Elabftw\Models\Notifications\UserCreated;
36
use Elabftw\Models\Notifications\UserNeedValidation;
37
use Elabftw\Services\EmailValidator;
38
use Elabftw\Services\Filter;
39
use Elabftw\Services\MfaHelper;
40
use Elabftw\Services\TeamsHelper;
41
use Elabftw\Services\UserArchiver;
42
use Elabftw\Services\UserCreator;
43
use Elabftw\Services\UsersHelper;
44
use PDO;
45
use Symfony\Component\HttpFoundation\Request;
0 ignored issues
show
Bug introduced by
The type Symfony\Component\HttpFoundation\Request was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
46
47
use function time;
48
use function trim;
49
50
/**
51
 * Users
52
 */
53
class Users implements RestInterface
54
{
55
    public bool $needValidation = false;
56
57
    public array $userData = array();
58
59
    public int $team = 0;
60
61
    public self $requester;
62
63
    public bool $isAdmin = false;
64
65
    protected Db $Db;
66
67 362
    public function __construct(public ?int $userid = null, ?int $team = null, ?self $requester = null)
68
    {
69 362
        $this->Db = Db::getConnection();
70 362
        if ($team !== null && $userid !== null) {
71 322
            $this->team = $team;
72 322
            $TeamsHelper = new TeamsHelper($team);
73 322
            $permissions = $TeamsHelper->getPermissions($userid);
74 322
            $this->isAdmin = (bool) $permissions['is_admin'];
75
        }
76 362
        if ($userid !== null) {
77 358
            $this->readOneFull();
78
        }
79 362
        $this->requester = $requester === null ? $this : $requester;
80
    }
81
82
    /**
83
     * Create a new user
84
     */
85 14
    public function createOne(
86
        string $email,
87
        array $teams,
88
        string $firstname = '',
89
        string $lastname = '',
90
        string $passwordHash = '',
91
        ?Usergroup $usergroup = null,
92
        bool $automaticValidationEnabled = false,
93
        bool $alertAdmin = true,
94
        ?string $validUntil = null,
95
        ?string $orgid = null,
96
    ): int {
97 14
        $Config = Config::getConfig();
98 14
        $Teams = new Teams($this);
99
100
        // make sure that all the teams in which the user will be are created/exist
101
        // this might throw an exception if the team doesn't exist and we can't create it on the fly
102 14
        $teams = $Teams->getTeamsFromIdOrNameOrOrgidArray($teams);
103 14
        $TeamsHelper = new TeamsHelper((int) $teams[0]['id']);
104
105 14
        $EmailValidator = new EmailValidator($email, $Config->configArr['email_domain']);
106 14
        $EmailValidator->validate();
107
108 14
        $firstname = trim($firstname);
109 14
        $lastname = trim($lastname);
110
111
        // Registration date is stored in epoch
112 14
        $registerDate = time();
113
114
        // get the user group for the new users
115 14
        $usergroup ??= $TeamsHelper->getGroup();
116
117 14
        $isSysadmin = $usergroup === Usergroup::Sysadmin;
118
119
        // is user validated automatically (true) or by an admin (false)?
120 14
        $isValidated = $automaticValidationEnabled || !$Config->configArr['admin_validate'] || $usergroup !== Usergroup::User;
121
122 14
        $defaultRead = BasePermissions::Team->toJson();
123 14
        $defaultWrite = BasePermissions::User->toJson();
124
125 14
        $sql = 'INSERT INTO users (
126
            `email`,
127
            `password_hash`,
128
            `firstname`,
129
            `lastname`,
130
            `register_date`,
131
            `validated`,
132
            `lang`,
133
            `valid_until`,
134
            `orgid`,
135
            `is_sysadmin`,
136
            `default_read`,
137
            `default_write`,
138
            `last_seen_version`
139
        ) VALUES (
140
            :email,
141
            :password_hash,
142
            :firstname,
143
            :lastname,
144
            :register_date,
145
            :validated,
146
            :lang,
147
            :valid_until,
148
            :orgid,
149
            :is_sysadmin,
150
            :default_read,
151
            :default_write,
152 14
            :last_seen_version);';
153 14
        $req = $this->Db->prepare($sql);
154
155 14
        $req->bindParam(':email', $email);
156 14
        $req->bindParam(':password_hash', $passwordHash);
157 14
        $req->bindParam(':firstname', $firstname);
158 14
        $req->bindParam(':lastname', $lastname);
159 14
        $req->bindParam(':register_date', $registerDate);
160 14
        $req->bindValue(':validated', $isValidated, PDO::PARAM_INT);
161 14
        $req->bindValue(':lang', $Config->configArr['lang']);
162 14
        $req->bindValue(':valid_until', $validUntil);
163 14
        $req->bindValue(':orgid', $orgid);
164 14
        $req->bindValue(':is_sysadmin', $isSysadmin, PDO::PARAM_INT);
165 14
        $req->bindValue(':default_read', $defaultRead);
166 14
        $req->bindValue(':default_write', $defaultWrite);
167 14
        $req->bindValue(':last_seen_version', App::INSTALLED_VERSION_INT);
168 14
        $this->Db->execute($req);
169 14
        $userid = $this->Db->lastInsertId();
170
171
        // check if the team is empty before adding the user to the team
172 14
        $isFirstUser = $TeamsHelper->isFirstUserInTeam();
173
        // now add the user to the team
174 14
        $Users2Teams = new Users2Teams($this->requester);
175
        // only send onboarding emails for new teams when user is validated
176 14
        if ($isValidated) {
177
            // do we send an email for the instance
178 14
            if ($Config->configArr['onboarding_email_active'] === '1') {
179 14
                $isAdmin = $usergroup === Usergroup::Admin || $usergroup === Usergroup::Sysadmin;
180 14
                (new OnboardingEmail(-1, $isAdmin))->create($userid);
181
            }
182
            // send email for each team
183 14
            $Users2Teams->sendOnboardingEmailOfTeams = true;
184
        }
185 14
        $Users2Teams->addUserToTeams(
186 14
            $userid,
187 14
            array_column($teams, 'id'),
188
            // transform Sysadmin to Admin because users2teams.groups_id is 2 (Admin) or 4 (User), but never 1 (Sysadmin)
189 14
            $usergroup === Usergroup::Sysadmin
190
                ? Usergroup::Admin
191 14
                : $usergroup,
192 14
        );
193 14
        if ($alertAdmin && !$isFirstUser) {
194 5
            $this->notifyAdmins($TeamsHelper->getAllAdminsUserid(), $userid, $isValidated, $teams[0]['name']);
195
        }
196 14
        if (!$isValidated) {
197
            $Notifications = new SelfNeedValidation();
198
            $Notifications->create($userid);
199
            // set a flag to show correct message to user
200
            $this->needValidation = true;
201
        }
202 14
        AuditLogs::create(new UserRegister($this->requester->userid ?? 0, $userid));
0 ignored issues
show
Bug introduced by
The type Elabftw\Models\AuditLogs was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
203 14
        return $userid;
204
    }
205
206
    /**
207
     * Search users based on query. It searches in email, firstname, lastname
208
     *
209
     * @param string $query the searched term
210
     * @param int $teamId limit search to a given team or search all teams if 0
211
     */
212 7
    public function readFromQuery(
213
        string $query,
214
        int $teamId = 0,
215
        bool $includeArchived = false,
216
        bool $onlyAdmins = false,
217
    ): array {
218 7
        $teamFilterSql = '';
219 7
        if ($teamId > 0) {
220 3
            $teamFilterSql = ' AND users2teams.teams_id = :team';
221
        }
222
223
        // Assures to get every user only once
224 7
        $tmpTable = ' (SELECT users_id, MIN(teams_id) AS teams_id, MIN(groups_id) AS groups_id
225
            FROM users2teams
226 7
            GROUP BY users_id) AS';
227
        // unless we use a specific team
228 7
        if ($teamId > 0) {
229 3
            $tmpTable = '';
230
        }
231
232 7
        $archived = '';
233 7
        if ($includeArchived) {
234 4
            $archived = ' OR users.archived = 1';
235
        }
236
237 7
        $admins = '';
238 7
        if ($onlyAdmins) {
239
            $admins = sprintf(' AND users2teams.groups_id = %d', Usergroup::Admin->value);
240
        }
241
242
        // NOTE: $tmpTable avoids the use of DISTINCT, so we are able to use ORDER BY with teams_id.
243
        // Side effect: User is shown in team with lowest id
244 7
        $sql = "SELECT users.userid,
245
            users.firstname, users.lastname, users.orgid, users.email, users.mfa_secret IS NOT NULL AS has_mfa_enabled,
246
            users.validated, users.archived, users.last_login, users.valid_until, users.is_sysadmin,
247
            CONCAT(users.firstname, ' ', users.lastname) AS fullname,
248
            users.orcid, users.auth_service, sig_keys.pubkey AS sig_pubkey
249
            FROM users
250
            LEFT JOIN sig_keys ON (sig_keys.userid = users.userid AND state = :state)
251 7
            CROSS JOIN" . $tmpTable . ' users2teams ON (users2teams.users_id = users.userid' . $teamFilterSql . $admins . ')
252
            WHERE (users.email LIKE :query OR users.firstname LIKE :query OR users.lastname LIKE :query)
253 7
            AND (users.archived = 0' . $archived . ')
254 7
            ORDER BY users2teams.teams_id ASC, users.lastname ASC';
255 7
        $req = $this->Db->prepare($sql);
256 7
        $req->bindValue(':query', '%' . $query . '%');
257 7
        $req->bindValue(':state', State::Normal->value, PDO::PARAM_INT);
258 7
        if ($teamId > 0) {
259 3
            $req->bindValue(':team', $teamId);
260
        }
261 7
        $this->Db->execute($req);
262
263 7
        return $req->fetchAll();
264
    }
265
266
    /**
267
     * Read all users from the team
268
     */
269 3
    public function readAllFromTeam(): array
270
    {
271 3
        return $this->readFromQuery('', $this->userData['team'], true);
272
    }
273
274 2
    public function readAllActiveFromTeam(): array
275
    {
276 2
        return array_filter($this->readAllFromTeam(), function ($u) {
277 2
            return $u['archived'] === 0;
278 2
        });
279
    }
280
281
    /**
282
     * This can be called from api and only contains "safe" values
283
     */
284 1
    public function readAll(): array
285
    {
286 1
        $Request = Request::createFromGlobals();
287 1
        return $this->readFromQuery(
288 1
            $Request->query->getAlnum('q'),
289 1
            0,
290 1
            $Request->query->getBoolean('includeArchived'),
291 1
            $Request->query->getBoolean('onlyAdmins'),
292 1
        );
293
    }
294
295
    /**
296
     * This can be called from api and only contains "safe" values
297
     */
298 14
    public function readOne(): array
299
    {
300 14
        $this->canReadOrExplode();
301 14
        $userData = $this->readOneFull();
302 14
        unset($userData['password']);
303 14
        unset($userData['password_hash']);
304 14
        unset($userData['salt']);
305 14
        unset($userData['mfa_secret']);
306 14
        unset($userData['token']);
307
        // keep sig_privkey in response if requester is target
308 14
        if ($this->requester->userData['userid'] !== $this->userData['userid']) {
309 4
            unset($userData['sig_privkey']);
310
        }
311 14
        return $userData;
312
    }
313
314 1
    public function readNamesFromIds(array $idArr): array
315
    {
316 1
        if (empty($idArr)) {
317 1
            return array();
318
        }
319
        $sql = "SELECT CONCAT(users.firstname, ' ', users.lastname) AS fullname, userid, email FROM users WHERE userid IN (" . implode(',', $idArr) . ') ORDER BY fullname ASC';
320
        $req = $this->Db->prepare($sql);
321
        $this->Db->execute($req);
322
323
        return $req->fetchAll();
324
    }
325
326 1
    public function isAdminSomewhere(): bool
327
    {
328 1
        $sql = sprintf(
329 1
            'SELECT users_id FROM users2teams WHERE users_id = :userid AND groups_id <= %d',
330 1
            Usergroup::Admin->value,
331 1
        );
332 1
        $req = $this->Db->prepare($sql);
333 1
        $req->bindParam(':userid', $this->userData['userid']);
334 1
        $this->Db->execute($req);
335 1
        return $req->rowCount() >= 1;
336
    }
337
338 2
    public function postAction(Action $action, array $reqBody): int
339
    {
340 2
        $Creator = new UserCreator($this->requester, $reqBody);
341 2
        return $Creator->create();
342
    }
343
344 21
    public function patch(Action $action, array $params): array
345
    {
346 21
        $this->canWriteOrExplode($action);
347 21
        match ($action) {
348 21
            Action::Add => (
349 21
                function () use ($params) {
350
                    // check instance config if admins are allowed to do that (if requester is not sysadmin)
351 1
                    $Config = Config::getConfig();
352 1
                    if ($this->requester->userData['is_sysadmin'] !== 1 && $Config->configArr['admins_import_users'] !== '1') {
353 1
                        throw new IllegalActionException('A non sysadmin user tried to import a user but admins_import_users is disabled in config.');
354
                    }
355
                    // need to be admin to "import" a user in a team
356 1
                    $team = (int) $params['team'];
357 1
                    $TeamsHelper = new TeamsHelper($team);
358 1
                    $permissions = $TeamsHelper->getPermissions($this->requester->userData['userid']);
359 1
                    if ($permissions['is_admin'] !== 1 && $this->requester->userData['is_sysadmin'] !== 1) {
360
                        throw new IllegalActionException('Only Admin can add a user to a team (where they are Admin)');
361
                    }
362 1
                    $Users2Teams = new Users2Teams($this->requester);
363 1
                    if ($this->userData['validated']) {
364 1
                        $Users2Teams->sendOnboardingEmailOfTeams = true;
365
                    }
366 1
                    $Users2Teams->create($this->userData['userid'], $team);
367 21
                }
368 21
            )(),
369 21
            Action::Disable2fa => $this->disable2fa(),
370 21
            Action::PatchUser2Team => (new Users2Teams($this->requester))->PatchUser2Team($params),
371 21
            Action::Unreference => (new Users2Teams($this->requester))->destroy($this->userData['userid'], (int) $params['team']),
372 21
            Action::Lock, Action::Archive => (new UserArchiver($this->requester, $this))->toggleArchive((bool) $params['with_exp']),
373 21
            Action::UpdatePassword => $this->updatePassword($params),
374 21
            Action::Update => (
375 21
                function () use ($params) {
376 9
                    foreach ($params as $target => $content) {
377 9
                        $this->update(new UserParams($target, (string) $content));
378
                    }
379 21
                }
380 21
            )(),
381 21
            Action::Validate => $this->validate(),
382 21
            default => throw new ImproperActionException('Invalid action parameter.'),
383 21
        };
384 13
        return $this->readOne();
385
    }
386
387 1
    public function getPage(): string
388
    {
389 1
        return 'api/v2/users/';
390
    }
391
392
    /**
393
     * Invalidate token on logout action
394
     */
395 5
    public function invalidateToken(): bool
396
    {
397 5
        $sql = 'UPDATE users SET token = null WHERE userid = :userid';
398 5
        $req = $this->Db->prepare($sql);
399 5
        $req->bindParam(':userid', $this->userData['userid'], PDO::PARAM_INT);
400 5
        return $this->Db->execute($req);
401
    }
402
403 1
    public function allowUntrustedLogin(): bool
404
    {
405 1
        $sql = 'SELECT allow_untrusted, auth_lock_time > (NOW() - INTERVAL 1 HOUR) AS currently_locked FROM users WHERE userid = :userid';
406 1
        $req = $this->Db->prepare($sql);
407 1
        $req->bindParam(':userid', $this->userData['userid'], PDO::PARAM_INT);
408 1
        $req->execute();
409 1
        $res = $req->fetch();
410
411 1
        if ($res['allow_untrusted'] === 1) {
412 1
            return true;
413
        }
414
        // check for the time when it was locked
415 1
        return $res['currently_locked'] === 0;
416
    }
417
418
    /**
419
     * Destroy user. Will completely remove everything from the user.
420
     */
421 3
    public function destroy(): bool
422
    {
423 3
        $this->canWriteOrExplode();
424
425 3
        $UsersHelper = new UsersHelper($this->userData['userid']);
426 3
        if ($UsersHelper->cannotBeDeleted()) {
427 1
            throw new ImproperActionException('Cannot delete a user that owns experiments, items, comments, templates or uploads!');
428
        }
429
430
        // Due to the InnoDB cascading actions of the foreign key constraints the deletion will also happen in the other tables
431 2
        $sql = 'DELETE FROM users WHERE userid = :userid';
432 2
        $req = $this->Db->prepare($sql);
433 2
        $req->bindParam(':userid', $this->userData['userid'], PDO::PARAM_INT);
434 2
        return $this->Db->execute($req);
435
    }
436
437
    /**
438
     * Check if this instance's user is admin of the userid in function argument
439
     */
440 19
    public function isAdminOf(int $userid): bool
441
    {
442
        // consider that we are admin of ourselves
443 19
        if ($this->userid === $userid) {
444 13
            return true;
445
        }
446
        // check if in the teams we have in common, the potential admin is admin
447 9
        $sql = sprintf(
448 9
            'SELECT *
449
                FROM users2teams u1
450
                INNER JOIN users2teams u2
451
                    ON (u1.teams_id = u2.teams_id)
452
                WHERE u1.users_id = :admin_userid
453
                    AND u2.users_id = :user_userid
454 9
                    AND u1.groups_id <= %d',
455 9
            Usergroup::Admin->value,
456 9
        );
457 9
        $req = $this->Db->prepare($sql);
458 9
        $req->bindParam(':admin_userid', $this->userid, PDO::PARAM_INT);
459 9
        $req->bindParam(':user_userid', $userid, PDO::PARAM_INT);
460 9
        $req->execute();
461 9
        return $req->rowCount() >= 1;
462
    }
463
464
    /**
465
     * For when password must be different than older one
466
     * Here the happy path is in the catch... Not great, not terrible...
467
     */
468
    public function requireResetPassword(string $password): bool
469
    {
470
        $LocalAuth = new Local($this->userData['email'], $password);
471
        try {
472
            $LocalAuth->tryAuth();
473
        } catch (InvalidCredentialsException) {
474
            return $this->updatePassword(array('password' => $password), true);
475
        }
476
        throw new ImproperActionException(_('New password must not be the same as the current one.'));
477
    }
478
479
    /**
480
     * This function allows us to set a new password without having to provide the old password
481
     */
482 1
    public function resetPassword(string $password): bool
483
    {
484 1
        return $this->updatePassword(array('password' => $password), true);
485
    }
486
487 4
    public function checkCurrentPasswordOrExplode(?string $currentPassword): void
488
    {
489 4
        if (empty($currentPassword)) {
490 1
            throw new ImproperActionException('Current password must be provided by "current_password" parameter.');
491
        }
492 3
        $LocalAuth = new Local($this->userData['email'], $currentPassword);
493
        try {
494 3
            $LocalAuth->tryAuth();
495 1
        } catch (InvalidCredentialsException) {
496 1
            throw new ImproperActionException('The current password is not valid!');
497
        }
498
    }
499
500 32
    protected static function search(string $column, string $term, bool $validated = false): self
501
    {
502 32
        $searchColumn = 'email';
503 32
        if ($column === 'orgid') {
504 3
            $searchColumn = 'orgid';
505
        }
506 32
        $validatedFilter = '';
507 32
        if ($validated) {
508 1
            $validatedFilter = ' AND validated = 1 ';
509
        }
510 32
        $Db = Db::getConnection();
511 32
        $sql = sprintf('SELECT userid FROM users WHERE %s = :term AND archived = 0 %s LIMIT 1', $searchColumn, $validatedFilter);
512 32
        $req = $Db->prepare($sql);
513 32
        $req->bindParam(':term', $term);
514 32
        $Db->execute($req);
515 32
        $res = $req->fetchColumn();
516 32
        if ($res === false) {
517 12
            throw new ResourceNotFoundException();
518
        }
519 23
        return new self((int) $res);
520
    }
521
522 3
    private function disable2fa(): array
523
    {
524
        // only sysadmin or same user can disable 2fa
525 3
        if ($this->requester->userData['userid'] === $this->userData['userid'] || $this->requester->userData['is_sysadmin'] === 1) {
526 2
            (new MfaHelper($this->userData['userid']))->removeSecret();
527 2
            return $this->readOne();
528
        }
529 1
        throw new IllegalActionException('User tried to disable 2fa but is not sysadmin or same user.');
530
    }
531
532 14
    private function canReadOrExplode(): void
533
    {
534
        // it's ourself or we are sysadmin
535
536
        // FIXME To investigate: $this->requester->userid is a string here!!!
537 14
        if ($this->requester->userData['userid'] === $this->userid || $this->requester->userData['is_sysadmin'] === 1) {
538 13
            return;
539
        }
540 1
        if (!$this->requester->isAdmin && $this->userid !== $this->userData['userid']) {
541
            throw new IllegalActionException('This endpoint requires admin privileges to access other users.');
542
        }
543
        // check we view user of our team, unless we are sysadmin and we can access it
544 1
        if ($this->userid !== null && !$this->requester->isAdminOf($this->userid)) {
545
            throw new IllegalActionException('User tried to access user from other team.');
546
        }
547
    }
548
549 9
    private function update(UserParams $params): bool
550
    {
551 9
        if ($params->getTarget() === 'password') {
552 1
            throw new ImproperActionException('Use action:updatepassword to update the password');
553
        }
554
        // email is filtered here because otherwise the check for existing email will throw exception
555 8
        if ($params->getTarget() === 'email' && $params->getContent() !== $this->userData['email']) {
556
            // we can only edit our own email, or be sysadmin
557 2
            if (($this->requester->userData['userid'] !== $this->userData['userid']) && ($this->requester->userData['is_sysadmin'] !== 1)) {
558
                throw new IllegalActionException('User tried to edit email of another user but is not sysadmin.');
559
            }
560 2
            Filter::email($params->getContent());
561
        }
562
        // special case for is_sysadmin: only a sysadmin can affect this column
563 8
        if ($params->getTarget() === 'is_sysadmin') {
564 1
            if ($this->requester->userData['is_sysadmin'] === 0) {
565 1
                throw new IllegalActionException('Non sysadmin user tried to edit the is_sysadmin column of a user');
566
            }
567
        }
568
569 7
        $sql = 'UPDATE users SET ' . $params->getColumn() . ' = :content WHERE userid = :userid';
570 7
        $req = $this->Db->prepare($sql);
571 7
        $req->bindValue(':content', $params->getContent());
572 6
        $req->bindParam(':userid', $this->userData['userid'], PDO::PARAM_INT);
573 6
        $res = $this->Db->execute($req);
574
575 6
        $auditLoggableTargets = array(
576 6
            'email',
577 6
            'orgid',
578 6
            'is_sysadmin',
579 6
        );
580
581 6
        if ($res && in_array($params->getTarget(), $auditLoggableTargets, true)) {
582 2
            AuditLogs::create(new UserAttributeChanged(
583 2
                $this->requester->userid ?? 0,
584 2
                $this->userid ?? 0,
585 2
                $params->getTarget(),
586 2
                (string) $this->userData[$params->getTarget()],
587 2
                $params->getContent(),
588 2
            ));
589
        }
590 6
        return $res;
591
    }
592
593 6
    private function updatePassword(array $params, bool $isReset = false): bool
594
    {
595
        // a sysadmin or reset password page request doesn't need to provide the current password
596 6
        if ($this->requester->userData['is_sysadmin'] !== 1 && $isReset === false) {
597 4
            $this->checkCurrentPasswordOrExplode($params['current_password']);
598
        }
599 4
        if (empty($params['password'])) {
600 1
            throw new ImproperActionException('New password must be provided by "password" parameter.');
601
        }
602
        // when updating the password, we need to check for the presence and validity of the current_password
603
        // special case for password: we invalidate the stored token
604 3
        $this->invalidateToken();
605
        // this will properly hash the password
606 3
        $params = new UserParams('password', $params['password']);
607
        // don't use the update() function so it cannot be bypassed by setting Action::Update instead of Action::UpdatePassword
608 3
        $sql = 'UPDATE users SET password_hash = :content, password_modified_at = NOW() WHERE userid = :userid';
609 3
        $req = $this->Db->prepare($sql);
610 3
        $req->bindValue(':content', $params->getContent());
611 3
        $req->bindParam(':userid', $this->userData['userid'], PDO::PARAM_INT);
612 3
        $res = $this->Db->execute($req);
613 3
        AuditLogs::create(new PasswordChanged(
614 3
            $this->requester->userid ?? 0,
615 3
            $this->userid ?? 0,
616 3
            'password',
617 3
            'the old password',
618 3
            'the new password',
619 3
        ));
620 3
        return $res;
621
    }
622
623
    /**
624
     * Check if requester can act on this User
625
     */
626 24
    private function canWriteOrExplode(?Action $action = null): void
627
    {
628 24
        if ($this->requester->userData['is_sysadmin'] === 1) {
629 8
            return;
630
        }
631 17
        if (!$this->requester->isAdminOf($this->userData['userid']) && $action !== Action::Add) {
632 1
            throw new IllegalActionException(Tools::error(true));
633
        }
634
    }
635
636
    /**
637
     * Validate current user instance
638
     * Note: this could also be PATCHed?
639
     */
640 1
    private function validate(): array
641
    {
642 1
        $sql = 'UPDATE users SET validated = 1 WHERE userid = :userid';
643 1
        $req = $this->Db->prepare($sql);
644 1
        $req->bindParam(':userid', $this->userData['userid'], PDO::PARAM_INT);
645 1
        $this->Db->execute($req);
646 1
        $Notifications = new SelfIsValidated();
647 1
        $Notifications->create($this->userData['userid']);
648 1
        $this->sendOnboardingEmailsAfterValidation();
649 1
        return $this->readOne();
650
    }
651
652
    /**
653
     * Read all the columns (including sensitive ones) of the current user
654
     */
655 358
    private function readOneFull(): array
656
    {
657 358
        $sql = "SELECT users.*, sig_keys.privkey AS sig_privkey, sig_keys.pubkey AS sig_pubkey,
658
            CONCAT(users.firstname, ' ', users.lastname) AS fullname
659
            FROM users
660
            LEFT JOIN sig_keys ON (sig_keys.userid = users.userid AND state = :state)
661 358
            WHERE users.userid = :userid";
662 358
        $req = $this->Db->prepare($sql);
663 358
        $req->bindValue(':userid', $this->userid, PDO::PARAM_INT);
664 358
        $req->bindValue(':state', State::Normal->value, PDO::PARAM_INT);
665 358
        $this->Db->execute($req);
666
667 358
        $this->userData = $this->Db->fetch($req);
668 358
        $this->userData['team'] = $this->team;
669 358
        $UsersHelper = new UsersHelper($this->userData['userid']);
670 358
        $this->userData['teams'] = $UsersHelper->getTeamsFromUserid();
671 358
        return $this->userData;
672
    }
673
674 5
    private function notifyAdmins(array $admins, int $userid, bool $isValidated, string $team): void
675
    {
676 5
        $Notifications = new UserCreated($userid, $team);
677 5
        if (!$isValidated) {
678
            $Notifications = new UserNeedValidation($userid, $team);
679
        }
680 5
        foreach ($admins as $admin) {
681 5
            $Notifications->create((int) $admin);
682
        }
683
    }
684
685 1
    private function sendOnboardingEmailsAfterValidation(): void
686
    {
687
        // do we send an eamil for the instance
688 1
        if (Config::getConfig()->configArr['onboarding_email_active'] === '1') {
689 1
            (new OnboardingEmail(-1, $this->isAdmin))->create($this->userData['userid']);
690
        }
691
692
        // Check setting for each team individually
693 1
        foreach (array_column($this->userData['teams'], 'id') as $teamId) {
694 1
            if ((new Teams($this))->readOne()['onboarding_email_active'] === 1) {
695 1
                (new OnboardingEmail($teamId))->create($this->userData['userid']);
696
            }
697
        }
698
    }
699
}
700