Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.82% covered (success)
89.82%
344 / 383
79.55% covered (warning)
79.55%
35 / 44
CRAP
0.00% covered (danger)
0.00%
0 / 1
User
89.82% covered (success)
89.82%
344 / 383
79.55% covered (warning)
79.55%
35 / 44
149.12
0.00% covered (danger)
0.00%
0 / 1
 userData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 __construct
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
4.02
 addPerm
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getAuthSource
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserAuthSource
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAuthData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addAuth
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getUserByCookie
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
4
 getUserId
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 checkDisplayName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkMailAddress
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 searchUsers
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 createUser
89.19% covered (success)
89.19%
33 / 37
0.00% covered (danger)
0.00%
0 / 1
13.21
 getTenantQuotaEnforcer
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 isValidLogin
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getUserByLogin
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 createPassword
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
11
 deleteUser
87.50% covered (success)
87.50%
28 / 32
0.00% covered (danger)
0.00%
0 / 1
10.20
 error
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getAuthContainer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllUsers
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
9
 getUserById
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
5
 getUserData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setUserData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getLogin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserIdByEmail
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getUserIdByKeycloakSub
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getUserVisibilityByEmail
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 activateUser
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 getStatus
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 setStatus
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 setAuthSource
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getEncryptedPassword
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 changePassword
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 mailUser
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 isSuperAdmin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSuperAdmin
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getSuperAdminIds
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 terminateSessionId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 extractUserFromResult
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 fetchRowArray
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 setWebAuthnKeys
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getWebAuthnKeys
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 isEmailAddress
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The main User class.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Lars Tiedemann <php@larstiedemann.de>
12 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
13 * @author    Sarah Hermann <sayh@gmx.de>
14 * @copyright 2005-2026 phpMyFAQ Team
15 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
16 * @link      https://www.phpmyfaq.de
17 * @since     2005-09-17
18 */
19
20declare(strict_types=1);
21
22namespace phpMyFAQ;
23
24use Exception;
25use phpMyFAQ\Auth\AuthDriverInterface;
26use phpMyFAQ\Permission\MediumPermission;
27use phpMyFAQ\Permission\PermissionInterface;
28use phpMyFAQ\Tenant\TenantQuotaEnforcer;
29use phpMyFAQ\User\UserData;
30use Symfony\Component\HttpFoundation\Request;
31use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
32
33/**
34 * Class User
35 *
36 * @package phpMyFAQ
37 */
38/* @mago-expect lint:too-many-methods - legacy user aggregate; split into services is in progress */
39/* @mago-expect lint:kan-defect - legacy user aggregate; split into services is in progress */
40class User
41{
42    final public const string DEFAULT_ENCRYPTION_TYPE = 'hash';
43
44    final public const string ERROR_USER_ADD = 'Account could not be created. ';
45
46    final public const string ERROR_USER_CANNOT_CREATE_USER = 'User account could not be created. ';
47
48    final public const string ERROR_USER_CANNOT_CREATE_USERDATA = 'Entry for user data could not be created. ';
49
50    final public const string ERROR_USER_CANNOT_DELETE_USER = 'User account could not be deleted. ';
51
52    final public const string ERROR_USER_CANNOT_DELETE_USERDATA = 'Entry for user data could not be deleted. ';
53
54    final public const string ERROR_USER_CHANGE = 'Account could not be updated. ';
55
56    final public const string ERROR_USER_DELETE = 'Account could not be deleted. ';
57
58    final public const string ERROR_USER_INCORRECT_LOGIN = 'The login name could not be found. ';
59
60    /* @mago-expect lint:no-literal-password - user-facing error message, not a credential */
61    final public const string ERROR_USER_INCORRECT_PASSWORD = 'The password is not correct.';
62
63    final public const string ERROR_USER_INVALID_STATUS = 'Undefined user status.';
64
65    final public const string ERROR_USER_LOGINNAME_TOO_SHORT = 'The chosen login name is too short.';
66
67    final public const string ERROR_USER_LOGIN_NOT_UNIQUE = 'The Login name already exists.';
68
69    final public const string ERROR_USER_EMAIL_NOT_UNIQUE = 'The email address already exists.';
70
71    final public const string ERROR_USER_LOGIN_INVALID =
72        'The chosen login is invalid. A valid login has at least '
73            . 'four characters. Only letters, numbers and underscore _ are allowed. The first letter must be a letter. ';
74
75    final public const string ERROR_USER_NO_USERID = 'No user-ID found. ';
76
77    final public const string ERROR_USER_NO_USERLOGINDATA = 'No user login data found. ';
78
79    final public const string ERROR_USER_NOT_FOUND = 'User account could not be found. ';
80
81    final public const string ERROR_USER_NO_AUTH_WRITABLE = 'No authentication object is writable.';
82
83    final public const string ERROR_USER_TOO_MANY_FAILED_LOGINS = 'You exceeded the maximum amounts of login attempts and are temporarily blocked. Please try again later.';
84
85    final public const string STATUS_USER_PROTECTED = 'User account is protected. ';
86
87    final public const string STATUS_USER_BLOCKED = 'User account is blocked. ';
88
89    final public const string STATUS_USER_ACTIVE = 'User account is active. ';
90
91    public PermissionInterface $perm;
92
93    public ?UserData $userdata = null;
94
95    /**
96     * Returns the user-data container, creating it on first use.
97     */
98    public function userData(): UserData
99    {
100        if (!$this->userdata instanceof UserData) {
101            $this->userdata = new UserData($this->configuration);
102        }
103
104        return $this->userdata;
105    }
106
107    /**
108     * Public array that contains error messages.
109     * @var array<string>
110     */
111    public array $errors = [];
112
113    /**
114     * authentication container.
115     * @var array<string, Auth&AuthDriverInterface>
116     */
117    protected array $authContainer = [];
118
119    /**
120     * Default Authentication properties.
121     *
122     * @var array<string, array<string, string>|string|false>
123     */
124    private array $authData = [
125        'authSource' => [
126            'name' => 'database',
127            'type' => 'local',
128        ],
129        'encType' => self::DEFAULT_ENCRYPTION_TYPE,
130        'readOnly' => false,
131    ];
132
133    private string $login = '';
134
135    private int $loginMinLength = 2;
136
137    /**
138     * regular expression to find invalid login strings
139     * (default: /^[a-z0-9][\w\.\-@]+/is ).
140     */
141    private string $validUsername = '/^[a-z0-9][\w.\-@]+/i';
142
143    private int $userId = -1;
144
145    private string $status = '';
146
147    /** Is the user a super admin? */
148    private bool $isSuperAdmin = false;
149
150    /** @var string $authSource Authentication, e.g. local, ldap, azure, sso, ... */
151    private string $authSource = 'local';
152    private ?TenantQuotaEnforcer $tenantQuotaEnforcer = null;
153
154    /**
155     * array of allowed values for status.
156     *
157     * @var array<string>
158     */
159    private array $allowedStatus = [
160        'active' => self::STATUS_USER_ACTIVE,
161        'blocked' => self::STATUS_USER_BLOCKED,
162        'protected' => self::STATUS_USER_PROTECTED,
163    ];
164
165    /**
166     * Constructor.
167     *
168     * @throws Core\Exception
169     */
170    public function __construct(
171        protected Configuration $configuration,
172    ) {
173        $basicPermission = Permission::create(
174            (string) $this->configuration->get(item: 'security.permLevel'),
175            $this->configuration,
176        );
177        $this->addPerm($basicPermission);
178
179        // Always create a 'local' authentication object (see: $authData)
180        $this->authContainer = [];
181        $auth = new Auth($this->configuration);
182
183        $selectedAuth = $auth->selectAuth($this->getAuthSource('name') ?? 'database');
184        $selectedAuth->getEncryptionContainer(
185            (string) ($this->getAuthData('encType') ?? self::DEFAULT_ENCRYPTION_TYPE),
186        );
187        $selectedAuth->disableReadOnly();
188        if ($this->getAuthData(key: 'readOnly')) {
189            $selectedAuth->enableReadOnly();
190        }
191
192        $this->addAuth($selectedAuth, $this->getAuthSource('type') ?? 'local');
193
194        // additionally, set given $auth objects
195        foreach ($this->authContainer as $name => $authObject) {
196            if ($this->addAuth($authObject, $name)) {
197                continue;
198            }
199
200            break;
201        }
202
203        // user data object
204        $this->userdata = new UserData($this->configuration);
205    }
206
207    /**
208     * Adds a permission object to the user.
209     *
210     * @param PermissionInterface $permission Permission object
211     */
212    public function addPerm(PermissionInterface $permission): bool
213    {
214        $this->perm = $permission;
215        return true;
216    }
217
218    /**
219     * Returns a specific entry from the auth data source array.
220     */
221    public function getAuthSource(string $key): ?string
222    {
223        return $this->authData['authSource'][$key] ?? null;
224    }
225
226    public function getUserAuthSource(): string
227    {
228        return $this->authSource;
229    }
230
231    /**
232     * Returns a specific entry from the auth data array.
233     */
234    public function getAuthData(string $key): mixed
235    {
236        return $this->authData[$key] ?? null;
237    }
238
239    /**
240     * adds a new authentication object to the user object.
241     *
242     * @param Auth&AuthDriverInterface $authDriver Driver object
243     * @param string              $name       Auth name
244     */
245    public function addAuth(Auth&AuthDriverInterface $authDriver, string $name): bool
246    {
247        $this->authContainer[$name] = $authDriver;
248        return true;
249    }
250
251    /**
252     * Loads basic user information from the database selecting the user with
253     * specified cookie information.
254     */
255    public function getUserByCookie(string $cookie): bool
256    {
257        $select = sprintf(
258            "
259            SELECT
260                user_id,
261                login,
262                account_status
263            FROM
264                %sfaquser
265            WHERE
266                remember_me = '%s' AND account_status != 'blocked'",
267            Database::getTablePrefix(),
268            // The cookie holds the raw token; only its SHA-256 hash is stored, so hash the
269            // incoming value before comparing.
270            $this->configuration->getDb()->escape(hash('sha256', $cookie)),
271        );
272
273        $res = $this->configuration->getDb()->query($select);
274        if ($this->configuration->getDb()->numRows($res) !== 1) {
275            $this->errors[] = self::ERROR_USER_INCORRECT_LOGIN;
276
277            return false;
278        }
279
280        $user = $this->configuration->getDb()->fetchArray($res);
281        if (!is_array($user)) {
282            return false;
283        }
284
285        // Don't ever log in via an anonymous user
286        if (-1 === (int) $user['user_id']) {
287            return false;
288        }
289
290        $this->userId = (int) $user['user_id'];
291        $this->login = (string) $user['login'];
292        $this->status = (string) $user['account_status'];
293
294        // get user-data
295        $this->userData()->load($this->getUserId());
296
297        return true;
298    }
299
300    /**
301     * Returns the User ID of the user.
302     */
303    public function getUserId(): int
304    {
305        if ($this->userId !== 0) {
306            return $this->userId;
307        }
308
309        $this->userId = -1;
310        $this->errors[] = self::ERROR_USER_NO_USERID;
311
312        return -1;
313    }
314
315    /**
316     * Checks if the display name is already used. Returns true, if already in use.
317     */
318    public function checkDisplayName(string $name): bool
319    {
320        return $name === $this->userData()->fetch('display_name', $name);
321    }
322
323    /**
324     * Checks if the email address is already used. Returns true, if already in use.
325     */
326    public function checkMailAddress(string $name): bool
327    {
328        return $name === $this->userData()->fetch('email', $name);
329    }
330
331    /**
332     * Search users by login.
333     *
334     * @param string $search Login name
335     * @return array<string[]>
336     */
337    public function searchUsers(string $search): array
338    {
339        $select = sprintf(
340            "SELECT login, user_id, account_status FROM %sfaquser WHERE login LIKE '%s'",
341            Database::getTablePrefix(),
342            $this->configuration->getDb()->escape($search . '%'),
343        );
344
345        $res = $this->configuration->getDb()->query($select);
346        if (!$res) {
347            return [];
348        }
349
350        $result = [];
351        while (true) {
352            $row = $this->configuration->getDb()->fetchArray($res);
353            if (!is_array($row) || $row === []) {
354                break;
355            }
356
357            $result[] = array_map(static fn(mixed $value): string => (string) $value, $row);
358        }
359
360        return $result;
361    }
362
363    /**
364     * Creates a new user and stores basic data in the database.
365     *
366     * @throws Core\Exception
367     * @throws Exception
368     */
369    public function createUser(string $login, string $pass = '', string $domain = '', int $userId = 0): bool
370    {
371        // is $login valid?
372        if (!$this->isValidLogin($login)) {
373            throw new Exception(self::ERROR_USER_LOGINNAME_TOO_SHORT);
374        }
375
376        // does $login already exist?
377        if ($this->getUserByLogin($login, false)) {
378            throw new Exception(self::ERROR_USER_LOGIN_NOT_UNIQUE);
379        }
380
381        // If $login is an email address, check if it already exists in the userdata table
382        if ($this->isEmailAddress($login)) {
383            if ($this->userData()->emailExists($login)) {
384                throw new Exception(self::ERROR_USER_EMAIL_NOT_UNIQUE);
385            }
386        }
387
388        $this->getTenantQuotaEnforcer()->assertCanCreateUser();
389
390        // set user-ID
391        $this->userId = $userId;
392        if (0 === $userId) {
393            $this->userId = $this->configuration->getDb()->nextId(Database::getTablePrefix() . 'faquser', 'user_id');
394        }
395
396        // create a user entry
397        $insert = sprintf(
398            "INSERT INTO %sfaquser (user_id, login, session_timestamp, member_since) VALUES (%d, '%s', %d, '%s')",
399            Database::getTablePrefix(),
400            $this->getUserId(),
401            $this->configuration->getDb()->escape($login),
402            (int) Request::createFromGlobals()->server->get('REQUEST_TIME'),
403            date(format: 'YmdHis', timestamp: (int) Request::createFromGlobals()->server->get('REQUEST_TIME')),
404        );
405
406        $this->configuration->getDb()->query($insert);
407        $data = $this->userData()->add($this->getUserId());
408        if (!$data) {
409            throw new Exception(self::ERROR_USER_CANNOT_CREATE_USERDATA);
410        }
411
412        // create authentication entry
413        if ($pass === '') {
414            $pass = $this->createPassword();
415        }
416
417        $success = false;
418        foreach ($this->authContainer as $name => $auth) {
419            if ($auth->disableReadOnly()) {
420                continue;
421            }
422
423            if (!$auth->create($login, $pass, $domain)) {
424                throw new Exception(self::ERROR_USER_CANNOT_CREATE_USER . 'in Auth ' . $name);
425            }
426
427            $success = true;
428        }
429
430        if (!$success) {
431            return false;
432        }
433
434        if ($this->perm instanceof MediumPermission) {
435            $this->perm->autoJoin($this->userId);
436        }
437
438        return $this->getUserByLogin($login, false);
439    }
440
441    private function getTenantQuotaEnforcer(): TenantQuotaEnforcer
442    {
443        return $this->tenantQuotaEnforcer ??= TenantQuotaEnforcer::createFromDatabaseDriver(
444            $this->configuration->getDb(),
445        );
446    }
447
448    /**
449     * Returns true if login is a valid login string.
450     * $this->loginMinLength defines the minimum length of the login string.
451     * If login has more characters than allowed, false is returned.
452     * $this->login_invalidRegExp is a regular expression.
453     * If login matches this false is returned.
454     *
455     * @param string $login Login name
456     */
457    public function isValidLogin(string $login): bool
458    {
459        if (strlen($login) < $this->loginMinLength || !preg_match($this->validUsername, $login)) {
460            $this->errors[] = self::ERROR_USER_LOGIN_INVALID;
461
462            return false;
463        }
464
465        return true;
466    }
467
468    /**
469     * loads basic user information from the database selecting the user with
470     * specified login.
471     *
472     * @param string $login Login name
473     * @param bool   $raiseError Raise error?
474     * @throws Exception
475     */
476    public function getUserByLogin(string $login, bool $raiseError = true): bool
477    {
478        $select = sprintf(
479            'SELECT user_id, login, account_status, is_superadmin, auth_source FROM %sfaquser WHERE login = ?',
480            Database::getTablePrefix(),
481        );
482
483        $result = $this->configuration->getDb()->queryPrepared($select, [$login]);
484        if ($this->configuration->getDb()->numRows($result) !== 1) {
485            if ($raiseError) {
486                $this->errors[] = self::ERROR_USER_INCORRECT_LOGIN;
487            }
488
489            return false;
490        }
491
492        $this->extractUserFromResult($result);
493
494        $this->userData()->load($this->getUserId());
495
496        return true;
497    }
498
499    /**
500     * Returns a new password.
501     *
502     * @throws Exception
503     */
504    public function createPassword(int $minimumLength = 8, bool $allowUnderscore = true): string
505    {
506        // To make passwords harder to get wrong, a few letters & numbers have been omitted.
507        // This will ensure safety with browsers using fonts with confusable letters.
508        // Removed: o,O,0,1,l,L
509        $consonants = ['b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'm', 'n', 'p', 'r', 's', 't', 'v', 'w', 'x', 'y', 'z'];
510        $vowels = ['a', 'e', 'i', 'u'];
511        $newPassword = '';
512        $nextChar = '';
513        $skipped = false;
514
515        while (strlen($newPassword) < $minimumLength) {
516            $caseFunc = random_int(min: 0, max: 1) !== 0 ? 'strtoupper' : 'strtolower';
517
518            $randomMax = 4;
519            if ($skipped) {
520                $randomMax = 3;
521            }
522
523            if (!$skipped && $allowUnderscore) {
524                $randomMax = 5;
525            }
526
527            $roll = random_int(min: 0, max: $randomMax);
528            if ($roll === 5) {
529                /* @mago-expect lint:no-literal-password - appends a literal underscore in the password generator */
530                $newPassword .= '_';
531                continue;
532            }
533
534            $nextChar = match (true) {
535                $roll <= 1 => $caseFunc($consonants[random_int(min: 0, max: 18)]),
536                $roll <= 3 => $caseFunc($vowels[random_int(min: 0, max: 3)]),
537                default => (string) random_int(min: 2, max: 9),
538            };
539
540            $skipped = false;
541
542            // Ensure letters and numbers only occur once.
543            if (!str_contains($newPassword, $nextChar)) {
544                $newPassword .= $nextChar;
545                continue;
546            }
547
548            $skipped = true;
549        }
550
551        return $newPassword;
552    }
553
554    /**
555     * deletes the user from the database.
556     */
557    public function deleteUser(): bool
558    {
559        if ($this->userId === 0) {
560            $this->errors[] = self::ERROR_USER_NO_USERID;
561
562            return false;
563        }
564
565        if ($this->login === '') {
566            $this->errors[] = self::ERROR_USER_LOGIN_INVALID;
567
568            return false;
569        }
570
571        if (
572            array_key_exists($this->status, $this->allowedStatus)
573            && $this->allowedStatus[$this->status] === self::STATUS_USER_PROTECTED
574        ) {
575            $this->errors[] = self::ERROR_USER_CANNOT_DELETE_USER . self::STATUS_USER_PROTECTED;
576
577            return false;
578        }
579
580        $this->perm->refuseAllUserRights($this->userId);
581
582        $delete = sprintf('DELETE FROM %sfaquser WHERE user_id = %d', Database::getTablePrefix(), $this->userId);
583
584        $res = $this->configuration->getDb()->query($delete);
585        if (!$res) {
586            $this->errors[] = self::ERROR_USER_CANNOT_DELETE_USER . 'error: ' . $this->configuration->getDb()->error();
587
588            return false;
589        }
590
591        $data = $this->userData()->delete($this->getUserId());
592        if (!$data) {
593            $this->errors[] = self::ERROR_USER_CANNOT_DELETE_USERDATA;
594
595            return false;
596        }
597
598        $readOnly = 0;
599        $authCount = 0;
600        $delete = [];
601        foreach ($this->authContainer as $auth) {
602            ++$authCount;
603            if ($auth->disableReadOnly()) {
604                ++$readOnly;
605                continue;
606            }
607
608            $delete[] = $auth->delete($this->login);
609        }
610
611        if ($readOnly === $authCount) {
612            $this->errors[] = self::ERROR_USER_NO_AUTH_WRITABLE;
613        }
614
615        return in_array(true, $delete, strict: true);
616    }
617
618    /**
619     * Returns a string with error messages.
620     * The string returned by error() contains messages for all errors that during object processing.
621     * New lines separate messages.
622     * Error messages are stored in the public array errors.
623     */
624    public function error(): string
625    {
626        $message = '';
627
628        foreach ($this->errors as $error) {
629            $message .= $error . "<br>\n";
630        }
631
632        $this->errors = [];
633
634        return $message;
635    }
636
637    /**
638     * Returns the data aof the auth container.
639     *
640     * @return array<string, Auth&AuthDriverInterface>
641     */
642    public function getAuthContainer(): array
643    {
644        return $this->authContainer;
645    }
646
647    /**
648     * Returns an array with the user-IDs of all users found in
649     * the database. By default, the Anonymous User will not be returned.
650     *
651     * @param bool $withoutAnonymous Without anonymous?
652     * @param bool $allowBlockedUsers Allow blocked users as well, e.g. in admin
653     * @return array<int>
654     */
655    public function getAllUsers(bool $withoutAnonymous = true, bool $allowBlockedUsers = true): array
656    {
657        $query = sprintf(
658            'SELECT user_id FROM %sfaquser WHERE 1 = 1 %s %s ORDER BY user_id ASC',
659            Database::getTablePrefix(),
660            $withoutAnonymous ? 'AND user_id <> -1' : '',
661            $allowBlockedUsers ? '' : "AND account_status != 'blocked'",
662        );
663
664        $result = $this->configuration->getDb()->query($query);
665        if (!$result) {
666            return [];
667        }
668
669        $users = [];
670        if ($this->configuration->getDb()->numRows($result) === 0) {
671            return [];
672        }
673
674        while (true) {
675            $row = $this->configuration->getDb()->fetchArray($result);
676            if ($row === false || $row === null || $row === []) {
677                break;
678            }
679
680            $users[] = (int) $row['user_id'];
681        }
682
683        return $users;
684    }
685
686    /**
687     * Loads basic user information from the database selecting the user with
688     * specified user-ID.
689     *
690     * @param int  $userId User ID
691     * @param bool $allowBlockedUsers Allow blocked users as well, e.g. in admin
692     */
693    public function getUserById(int $userId, bool $allowBlockedUsers = false): bool
694    {
695        $select = sprintf(
696            '
697            SELECT
698                user_id, login, account_status, is_superadmin, auth_source
699            FROM
700                %sfaquser
701            WHERE
702                user_id = %d %s',
703            Database::getTablePrefix(),
704            $userId,
705            $allowBlockedUsers ? '' : "AND account_status != 'blocked'",
706        );
707
708        $result = $this->configuration->getDb()->query($select);
709        if ($this->configuration->getDb()->numRows($result) !== 1) {
710            $this->errors[] = self::ERROR_USER_NO_USERID . 'error(): ' . $this->configuration->getDb()->error();
711
712            return false;
713        }
714
715        $this->extractUserFromResult($result);
716
717        // get encrypted password
718        // @todo: Add a getEncPassword method to the Auth* classes for the (local and remote) Auth Sources.
719        if ('db' === $this->getAuthSource('name')) {
720            $select = sprintf(
721                "SELECT pass FROM %sfaquserlogin WHERE login = '%s'",
722                Database::getTablePrefix(),
723                $this->login,
724            );
725
726            $res = $this->configuration->getDb()->query($select);
727            if ($this->configuration->getDb()->numRows($res) !== 1) {
728                $this->errors[] =
729                    self::ERROR_USER_NO_USERLOGINDATA . 'error: ' . $this->configuration->getDb()->error();
730
731                return false;
732            }
733        }
734
735        // get user-data
736        $this->userData()->load($this->getUserId());
737
738        return true;
739    }
740
741    /**
742     * Returns the data of the current user.
743     *
744     * @param string $field Field
745     * @return ($field is '*' ? array<string, mixed> : string|int|null)
746     */
747    public function getUserData(string $field = '*'): mixed
748    {
749        /* @mago-expect analysis:mixed-return-statement - user data fields are heterogeneous by design */
750        return $this->userData()->get($field);
751    }
752
753    /**
754     * Adds user data.
755     *
756     * @param array<string, int|string> $data Array with user data
757     */
758    public function setUserData(array $data): bool
759    {
760        $userData = $this->userData();
761        $userData->load($this->getUserId());
762
763        return $userData->set(array_keys($data), array_values($data));
764    }
765
766    /**
767     * returns the user's login.
768     */
769    public function getLogin(): string
770    {
771        return $this->login;
772    }
773
774    /**
775     * Returns the user ID from the given email address
776     */
777    public function getUserIdByEmail(string $email): int
778    {
779        $userData = $this->userData()->fetchAll('email', $email);
780
781        return (int) ($userData['user_id'] ?? 0);
782    }
783
784    /**
785     * Returns the user ID from the given Keycloak subject.
786     */
787    public function getUserIdByKeycloakSub(string $keycloakSub): int
788    {
789        $userData = $this->userData()->fetchAll('keycloak_sub', $keycloakSub);
790
791        if (!array_key_exists('user_id', $userData)) {
792            return 0;
793        }
794
795        return (int) $userData['user_id'];
796    }
797
798    /**
799     * Returns true or false for the visibility for the given email
800     * address, if the user is not a registered user, the method
801     * returns false for anonymous users
802     */
803    public function getUserVisibilityByEmail(string $email): bool
804    {
805        $userData = $this->userData()->fetchAll('email', $email);
806
807        return !array_key_exists('is_visible', $userData) || (bool) $userData['is_visible'];
808    }
809
810    /**
811     * Returns true on success.
812     * This will change a users' status to active and send an email with a new password.
813     *
814     * @throws Exception|TransportExceptionInterface
815     */
816    public function activateUser(): bool
817    {
818        if ($this->getStatus() === 'blocked') {
819            // Generate and change user password.
820            $newPassword = $this->createPassword();
821            $this->changePassword($newPassword);
822            // Send activation email.
823            $subject = '[%sitename%] Login name / activation';
824            $displayName = $this->getUserData('display_name');
825            $message = sprintf(
826                'Name: %s<br>Login name: %s<br>New password: %s',
827                is_string($displayName) ? $displayName : '',
828                $this->getLogin(),
829                $newPassword,
830            );
831            // Only set to active if the activation mail sent correctly.
832            if ($this->mailUser($subject, $message) !== 0) {
833                return $this->setStatus('active');
834            }
835
836            return true;
837        }
838
839        return false;
840    }
841
842    /**
843     * returns the user's status.
844     */
845    public function getStatus(): string
846    {
847        if ($this->status === '') {
848            return '';
849        }
850
851        if (strlen($this->status) <= 0) {
852            return '';
853        }
854
855        return $this->status;
856    }
857
858    /**
859     * Sets the user's status and updates the database entry.
860     *
861     * @param string $status Status
862     */
863    public function setStatus(string $status): bool
864    {
865        // is status allowed?
866        $status = strtolower($status);
867        if (!in_array($status, array_keys($this->allowedStatus), strict: true)) {
868            $this->errors[] = self::ERROR_USER_INVALID_STATUS;
869
870            return false;
871        }
872
873        $this->status = $status;
874        $update = sprintf(
875            "UPDATE %sfaquser SET account_status = '%s' WHERE user_id = %d",
876            Database::getTablePrefix(),
877            $this->configuration->getDb()->escape($status),
878            $this->userId,
879        );
880
881        $res = $this->configuration->getDb()->query($update);
882        return (bool) $res;
883    }
884
885    /**
886     * Sets the auth container
887     */
888    public function setAuthSource(string $authSource): bool
889    {
890        $update = sprintf(
891            "UPDATE %sfaquser SET auth_source = '%s' WHERE user_id = %d",
892            Database::getTablePrefix(),
893            $this->configuration->getDb()->escape($authSource),
894            $this->getUserId(),
895        );
896
897        return (bool) $this->configuration->getDb()->query($update);
898    }
899
900    /**
901     * Returns the encrypted password (hash) stored for the local "db" auth source.
902     *
903     * Used as the per-user signing key for password-reset tokens: the token is
904     * implicitly invalidated as soon as the password changes. Returns an empty
905     * string for non-local auth sources (LDAP, Keycloak, OAuth2, ...) since the
906     * password is not owned by phpMyFAQ in that case.
907     */
908    public function getEncryptedPassword(): string
909    {
910        if ($this->getAuthSource('name') !== 'database') {
911            return '';
912        }
913
914        $login = $this->getLogin();
915        if ($login === '') {
916            return '';
917        }
918
919        $select = sprintf(
920            "SELECT pass FROM %sfaquserlogin WHERE login = '%s'",
921            Database::getTablePrefix(),
922            $this->configuration->getDb()->escape($login),
923        );
924
925        $result = $this->configuration->getDb()->query($select);
926        if (!$result) {
927            return '';
928        }
929
930        $row = $this->configuration->getDb()->fetchArray($result);
931        if (!is_array($row) || !array_key_exists('pass', $row) || !is_string($row['pass'])) {
932            return '';
933        }
934
935        return $row['pass'];
936    }
937
938    /**
939     * changes the user's password. If $pass is omitted, a new
940     * password is generated using the createPassword() method.
941     *
942     * @param string $pass Password
943     * @throws Exception
944     */
945    public function changePassword(string $pass = ''): bool
946    {
947        $login = $this->getLogin();
948        if ($pass === '') {
949            $pass = $this->createPassword();
950        }
951
952        $success = false;
953        foreach ($this->authContainer as $auth) {
954            if ($auth->disableReadOnly()) {
955                continue;
956            }
957
958            if (!$auth->update($login, $pass)) {
959                continue;
960            }
961
962            $success = true;
963        }
964
965        return $success;
966    }
967
968    /**
969     * Sends mail to the current user.
970     *
971     * @throws Core\Exception|TransportExceptionInterface
972     */
973    public function mailUser(string $subject, string $message): int
974    {
975        $mail = new Mail($this->configuration);
976        $email = $this->getUserData('email');
977        $mail->addTo(is_string($email) ? $email : '');
978
979        $mail->subject = $subject;
980        $mail->message = $message;
981
982        $result = $mail->send();
983        unset($mail);
984
985        return $result;
986    }
987
988    /**
989     * Returns true, if a user is a super admin.
990     */
991    public function isSuperAdmin(): bool
992    {
993        return $this->isSuperAdmin;
994    }
995
996    /**
997     * Sets the users "is_superadmin" flag and updates the database entry.
998     */
999    public function setSuperAdmin(bool $isSuperAdmin): bool
1000    {
1001        $this->isSuperAdmin = $isSuperAdmin;
1002        $update = sprintf(
1003            'UPDATE %sfaquser SET is_superadmin = %d WHERE user_id = %d',
1004            Database::getTablePrefix(),
1005            (int) $this->isSuperAdmin,
1006            $this->userId,
1007        );
1008
1009        $res = $this->configuration->getDb()->query($update);
1010        return (bool) $res;
1011    }
1012
1013    /**
1014     * Returns an array of user IDs that have the superadmin flag set.
1015     *
1016     * @return int[]
1017     */
1018    public static function getSuperAdminIds(Configuration $configuration): array
1019    {
1020        $query = sprintf('SELECT user_id FROM %sfaquser WHERE is_superadmin = 1', Database::getTablePrefix());
1021
1022        $result = $configuration->getDb()->query($query);
1023        if ($result === false) {
1024            return [];
1025        }
1026
1027        $superAdminIds = [];
1028        while (true) {
1029            $row = $configuration->getDb()->fetchObject($result);
1030            if ($row === false || $row === null || $row === []) {
1031                break;
1032            }
1033
1034            $superAdminIds[] = (int) $row->user_id;
1035        }
1036
1037        return $superAdminIds;
1038    }
1039
1040    /**
1041     * Terminates the session ID of user
1042     */
1043    public function terminateSessionId(): bool
1044    {
1045        $update = sprintf(
1046            "UPDATE %sfaquser SET session_id = '' WHERE user_id = %d",
1047            Database::getTablePrefix(),
1048            $this->userId,
1049        );
1050
1051        return (bool) $this->configuration->getDb()->query($update);
1052    }
1053
1054    public function extractUserFromResult(mixed $result): void
1055    {
1056        $user = array_merge([
1057            'user_id' => 0,
1058            'login' => '',
1059            'account_status' => '',
1060            'is_superadmin' => false,
1061            'auth_source' => '',
1062        ], $this->fetchRowArray($result));
1063
1064        $this->userId = (int) $user['user_id'];
1065        $this->login = (string) $user['login'];
1066        $this->status = (string) $user['account_status'];
1067        $this->isSuperAdmin = (bool) $user['is_superadmin'];
1068        $this->authSource = (string) $user['auth_source'];
1069    }
1070
1071    /**
1072     * Fetches the next result row as an associative array, or an empty array.
1073     *
1074     * @return array<array-key, mixed>
1075     */
1076    private function fetchRowArray(mixed $result): array
1077    {
1078        $row = $this->configuration->getDb()->fetchArray($result);
1079
1080        return is_array($row) ? $row : [];
1081    }
1082
1083    public function setWebAuthnKeys(string $webAuthnKeys): bool
1084    {
1085        $query = sprintf(
1086            "UPDATE %sfaquser SET webauthnkeys = '%s' WHERE user_id = %d",
1087            Database::getTablePrefix(),
1088            $this->configuration->getDb()->escape($webAuthnKeys),
1089            $this->getUserId(),
1090        );
1091
1092        return (bool) $this->configuration->getDb()->query($query);
1093    }
1094
1095    public function getWebAuthnKeys(): string
1096    {
1097        $select = sprintf(
1098            'SELECT webauthnkeys FROM %sfaquser WHERE user_id = %d',
1099            Database::getTablePrefix(),
1100            $this->getUserId(),
1101        );
1102
1103        $result = $this->configuration->getDb()->query($select);
1104        if ($this->configuration->getDb()->numRows($result) === 1) {
1105            $user = array_merge(['webauthnkeys' => ''], $this->fetchRowArray($result));
1106            $webAuthnKeys = $user['webauthnkeys'];
1107
1108            return is_string($webAuthnKeys) ? $webAuthnKeys : '';
1109        }
1110
1111        return '';
1112    }
1113
1114    /**
1115     * Checks if a string is a valid email address.
1116     *
1117     * @param string $string String to check
1118     */
1119    private function isEmailAddress(string $string): bool
1120    {
1121        return filter_var($string, FILTER_VALIDATE_EMAIL) !== false;
1122    }
1123}