Lines 89.38% 556 / 622
Methods 79.72% 59 / 74
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 accountStateConfiguration 100.00% 1 / 1 100.00% 1 / 1 1
 __construct 100.00% 3 / 3 100.00% 1 / 1 1
 login 80.76% 42 / 52 0.00% 0 / 1 32.18
 isLoggedIn 100.00% 1 / 1 100.00% 1 / 1 1
 twoFactorFailure 100.00% 1 / 1 100.00% 1 / 1 1
 isTwoFactorLockedOut 100.00% 1 / 1 100.00% 1 / 1 1
 twoFactorSuccess 100.00% 5 / 5 100.00% 1 / 1 1
 setLoggedIn 100.00% 1 / 1 100.00% 1 / 1 1
 sessionIsTimedOut 100.00% 1 / 1 100.00% 1 / 1 1
 sessionIdIsTimedOut 100.00% 1 / 1 100.00% 1 / 1 1
 sessionAge 100.00% 5 / 5 100.00% 1 / 1 2
 getSessionInfo 85.71% 6 / 7 0.00% 0 / 1 4.05
 updateSessionId 85.71% 24 / 28 0.00% 0 / 1 7.14
 saveToSession 100.00% 1 / 1 100.00% 1 / 1 1
 deleteFromSession 81.25% 13 / 16 0.00% 0 / 1 4.11
 setSessionTimeout 100.00% 1 / 1 100.00% 1 / 1 1
 enableRememberMe 100.00% 1 / 1 100.00% 1 / 1 1
 setAuthSource 100.00% 7 / 7 100.00% 1 / 1 1
 issueRememberMeCookie 100.00% 7 / 7 100.00% 1 / 1 1
 setRememberMe 100.00% 7 / 7 100.00% 1 / 1 1
 setSuccess 100.00% 11 / 11 100.00% 1 / 1 1
 setTokenData 100.00% 12 / 12 100.00% 1 / 1 1
 setLoginAttempt 100.00% 10 / 10 100.00% 1 / 1 1
 isFailedLastLoginAttempt 100.00% 1 / 1 100.00% 1 / 1 1
 hasExceededLoginAttempts 100.00% 11 / 11 100.00% 1 / 1 1
 sortAuthContainer 37.50% 3 / 8 0.00% 0 / 1 5.20
 [phpMyFAQ\User\CurrentUserAccountStateTrait] getUserId n/a 0 / 0 n/a 0 / 0 0
 [phpMyFAQ\User\CurrentUserAccountStateTrait] accountStateConfiguration n/a 0 / 0 n/a 0 / 0 0
 [phpMyFAQ\User\CurrentUserAccountStateTrait] isLocalUser 100.00% 8 / 8 100.00% 1 / 1 1
 [phpMyFAQ\User\CurrentUserAccountStateTrait] isBlocked 100.00% 9 / 9 100.00% 1 / 1 1
 [phpMyFAQ\User\CurrentUserSessionLookupTrait] getCurrentUser 100.00% 7 / 7 100.00% 1 / 1 3
 [phpMyFAQ\User\CurrentUserSessionLookupTrait] getCurrentUserGroupId 100.00% 9 / 9 100.00% 1 / 1 4
 [phpMyFAQ\User\CurrentUserSessionLookupTrait] getFromSession 77.27% 17 / 22 0.00% 0 / 1 9.95
 [phpMyFAQ\User\CurrentUserSessionLookupTrait] getFromCookie 90.90% 10 / 11 0.00% 0 / 1 3.01
 [phpMyFAQ\User] userData 100.00% 3 / 3 100.00% 1 / 1 2
 [phpMyFAQ\User] addPerm 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\User] getAuthSource 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\User] getUserAuthSource 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\User] getAuthData 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\User] addAuth 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\User] getUserByCookie 95.00% 19 / 20 0.00% 0 / 1 4
 [phpMyFAQ\User] getUserId 100.00% 5 / 5 100.00% 1 / 1 2
 [phpMyFAQ\User] checkDisplayName 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\User] checkMailAddress 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\User] searchUsers 100.00% 15 / 15 100.00% 1 / 1 5
 [phpMyFAQ\User] createUser 89.18% 33 / 37 0.00% 0 / 1 13.21
 [phpMyFAQ\User] getTenantQuotaEnforcer 100.00% 3 / 3 100.00% 1 / 1 1
 [phpMyFAQ\User] isValidLogin 100.00% 4 / 4 100.00% 1 / 1 3
 [phpMyFAQ\User] getUserByLogin 100.00% 12 / 12 100.00% 1 / 1 3
 [phpMyFAQ\User] createPassword 100.00% 25 / 25 100.00% 1 / 1 11
 [phpMyFAQ\User] deleteUser 87.50% 28 / 32 0.00% 0 / 1 10.20
 [phpMyFAQ\User] error 100.00% 5 / 5 100.00% 1 / 1 2
 [phpMyFAQ\User] getAuthContainer 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\User] getAllUsers 100.00% 18 / 18 100.00% 1 / 1 9
 [phpMyFAQ\User] getUserById 100.00% 25 / 25 100.00% 1 / 1 5
 [phpMyFAQ\User] getUserData 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\User] setUserData 100.00% 3 / 3 100.00% 1 / 1 1
 [phpMyFAQ\User] getLogin 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\User] getUserIdByEmail 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\User] getUserIdByKeycloakSub 75.00% 3 / 4 0.00% 0 / 1 2.06
 [phpMyFAQ\User] getUserVisibilityByEmail 100.00% 2 / 2 100.00% 1 / 1 2
 [phpMyFAQ\User] activateUser 100.00% 15 / 15 100.00% 1 / 1 4
 [phpMyFAQ\User] getStatus 80.00% 4 / 5 0.00% 0 / 1 3.07
 [phpMyFAQ\User] setStatus 100.00% 13 / 13 100.00% 1 / 1 2
 [phpMyFAQ\User] getEncryptedPassword 0.00% 0 / 17 0.00% 0 / 1 56
 [phpMyFAQ\User] changePassword 90.90% 10 / 11 0.00% 0 / 1 5.02
 [phpMyFAQ\User] mailUser 0.00% 0 / 8 0.00% 0 / 1 6
 [phpMyFAQ\User] isSuperAdmin 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\User] setSuperAdmin 100.00% 9 / 9 100.00% 1 / 1 1
 [phpMyFAQ\User] getSuperAdminIds 100.00% 11 / 11 100.00% 1 / 1 6
 [phpMyFAQ\User] terminateSessionId 100.00% 6 / 6 100.00% 1 / 1 1
 [phpMyFAQ\User] extractUserFromResult 100.00% 12 / 12 100.00% 1 / 1 1
 [phpMyFAQ\User] fetchRowArray 100.00% 2 / 2 100.00% 1 / 1 2
 [phpMyFAQ\User] setWebAuthnKeys 100.00% 7 / 7 100.00% 1 / 1 1
 [phpMyFAQ\User] getWebAuthnKeys 100.00% 11 / 11 100.00% 1 / 1 3
 [phpMyFAQ\User] isEmailAddress 100.00% 1 / 1 100.00% 1 / 1 1
50class CurrentUser extends User
51{
52    use CurrentUserAccountStateTrait;
53
54    #[\Override]
55    protected function accountStateConfiguration(): Configuration
56    {
57        return $this->configuration;
58    }
59
60    use CurrentUserSessionLookupTrait;
61
62    public const string SESSION_CURRENT_USER = 'CURRENT_USER';
63    public const string SESSION_ID_TIMESTAMP = 'SESSION_TIMESTAMP';
64
65    private const int PMF_REMEMBER_ME_EXPIRED_TIME = 1_209_600; // 2 weeks
66
67    private bool $loggedIn = false;
68
69    /**
70     * Specifies the timeout for the session in minutes. If the session ID was
71     * not updated for the last $this->sessionTimeout minutes, the CurrentUser
72     * will be logged out automatically if no cookie was set.
73     */
74    private int $sessionTimeout = PMF_AUTH_TIMEOUT;
75
76    /**
77     * The Session class object
78     */
79    private readonly UserSession $userSession;
80
81    /**
82     * The Session wrapper for Symfony Session
83     */
84    private readonly SessionWrapper $sessionWrapper;
85
86    /**
87     * Specifies the timeout for the session-ID in minutes. If the session ID
88     * was not updated for the last $this->sessionIdTimeout minutes, it will
89     * be updated. If set to 0, the session ID will be updated on every click.
90     * The session ID timeout must not be greater than Session timeout.
91     */
92    private int $sessionIdTimeout = 1;
93
94    /**
95     * Remember me activated or deactivated.
96     */
97    private bool $rememberMe = false;
98
99    /**
100     * Failed attempts above this number lock the account for the lockout time.
101     */
102    private const int MAX_FAILED_LOGIN_ATTEMPTS = 5;
103
104    /**
105     * Number of failed login attempts
106     */
107    private int $loginAttempts = 0;
108
109    /**
110     * Lockout time in seconds
111     */
112    private int $lockoutTime = 600;
113
114    /**
115     * Constructor.
116     *
117     * @throws Exception
118     * @throws \Exception
119     */
120    public function __construct(Configuration $configuration)
121    {
122        parent::__construct($configuration);
123        $this->userSession = new UserSession($configuration);
124        $this->sessionWrapper = new SessionWrapper();
125    }
126
127    /**
128     * Checks the given login and password in all auth-objects.
129     * Returns true for success, otherwise false.
130     * On success, the CurrentUser instance will be labeled as logged in.
131     * The name of the successful auth container will be stored in the user table.
132     * A new auth object may be added by using addAuth() method.
133     * The given password must not be encrypted, since the auth object takes care of the encryption method.
134     *
135     * @param string $login Login name
136     * @param string $password Password
137     * @throws UserException
138     * @throws AuthException
139     * @throws \Exception
140     */
141    public function login(string $login, #[SensitiveParameter] string $password): bool
142    {
143        $request = Request::createFromGlobals();
144
145        // Check if the login is an email address and convert it to a username if needed
146        if (
147            true === $this->configuration->get(item: 'security.loginWithEmailAddress')
148            && is_string(Filter::filterVar($login, FILTER_VALIDATE_EMAIL))
149        ) {
150            $userId = $this->getUserIdByEmail($login);
151            $this->getUserById($userId);
152            $login = $this->getLogin();
153        }
154
155        // First check for brute force attack. An unknown login leaves the user-ID at
156        // its -1 default, which is the guest account, so the lockout bookkeeping is
157        // skipped entirely rather than being applied to the wrong row.
158        $userExists = $this->getUserByLogin($login);
159        if ($userExists && $this->isFailedLastLoginAttempt()) {
160            throw new UserException(parent::ERROR_USER_TOO_MANY_FAILED_LOGINS);
161        }
162
163        // Extract domain if LDAP is active and ldap_use_domain_prefix is true
164        $optData = [];
165        if (
166            $this->configuration->isLdapActive()
167            && true === $this->configuration->get(item: 'ldap.ldap_use_domain_prefix')
168            && '' !== $password
169            && ($pos = strpos($login, needle: '\\')) !== false
170        ) {
171            $optData['domain'] = $pos !== 0 ? substr($login, offset: 0, length: $pos) : '';
172            $login = substr($login, $pos + 1);
173        }
174
175        // Handle SSO authentication
176        if (
177            true === $this->configuration->get(item: 'security.ssoSupport')
178            && '' !== (string) $request->server->get('REMOTE_USER')
179            && '' === $password
180        ) {
181            $ssoLogin = strtok($login, token: chr(64) . '\\');
182            $login = $ssoLogin === false ? $login : $ssoLogin;
183        }
184
185        // Attempt to authenticate a user by login and password
186        $this->authContainer = $this->sortAuthContainer($this->authContainer);
187        foreach ($this->authContainer as $authSource => $auth) {
188            if ($auth->isValidLogin($login, $optData) === 0) {
189                continue; // Login does not exist, try the next auth method
190            }
191
192            try {
193                $credentialsAreValid = $auth->checkCredentials($login, $password, $optData);
194            } catch (AuthException) {
195                // Drivers signal a wrong password by throwing; treat it as a failed
196                // attempt so the fall-through failure handling counts it for lockout.
197                $credentialsAreValid = false;
198            }
199
200            if (!$credentialsAreValid) {
201                continue; // Incorrect password, try the next auth method
202            }
203
204            // Login successful, proceed with post-login actions
205            $this->getUserByLogin($login);
206            if ((int) $this->getUserData('twofactor_enabled') !== 1) {
207                $this->setLoggedIn(true);
208                $this->updateSessionId(true);
209                $this->saveToSession();
210            }
211
212            if ($this->rememberMe) {
213                // A remember-me cookie is a password-equivalent credential: it grants a full
214                // session without a second factor. For a 2FA account it must therefore not be
215                // issued until the token step has succeeded, otherwise an attacker who only
216                // holds the password could obtain the cookie here and replay it to bypass 2FA.
217                // The controllers re-issue it via issueRememberMeCookie() once the second
218                // factor is verified.
219                if ((int) $this->getUserData('twofactor_enabled') !== 1) {
220                    $this->issueRememberMeCookie();
221                }
222            }
223
224            if (!$this->setAuthSource($authSource)) {
225                $this->setSuccess(false);
226                return false;
227            }
228
229            if ((int) $this->getUserData('twofactor_enabled') !== 1) {
230                $this->setSuccess(true);
231            }
232
233            return true; // Login successful
234        }
235
236        // No successful login: count the failed attempt so the account lockout engages.
237        // Unknown logins are skipped â€” their user-ID is the guest account's.
238        if ($userExists) {
239            $this->setLoginAttempt();
240        }
241
242        if (
243            true === $this->configuration->get(item: 'security.loginWithEmailAddress')
244            && !is_string(Filter::filterVar($login, FILTER_VALIDATE_EMAIL))
245        ) {
246            throw new UserException(parent::ERROR_USER_INCORRECT_LOGIN);
247        }
248
249        if (!$this->isFailedLastLoginAttempt()) {
250            throw new UserException(parent::ERROR_USER_INCORRECT_PASSWORD);
251        }
252
253        throw new UserException(parent::ERROR_USER_TOO_MANY_FAILED_LOGINS);
254    }
255
256    /**
257     * Returns true if CurrentUser is logged in, otherwise false.
258     */
259    public function isLoggedIn(): bool
260    {
261        return $this->loggedIn;
262    }
263
264    /**
265     * Records a failed second-factor attempt.
266     *
267     * The token step is part of the login, so its failures consume the same
268     * per-account budget as failed passwords. Keeping the count in the database
269     * rather than in the session is what makes the throttle effective: an attacker
270     * who already holds the password could otherwise reset a session counter at
271     * will, simply by authenticating again to obtain a fresh session.
272     */
273    public function twoFactorFailure(): bool
274    {
275        return (bool) $this->setLoginAttempt();
276    }
277
278    /**
279     * Returns true while the account is locked out of the second-factor step.
280     *
281     * Like the password lockout this deliberately ignores the client IP: reaching
282     * this step means the password is already known, so allowing a different IP to
283     * start from a clean budget would hand the attacker an unlimited number of
284     * guesses for the price of a proxy.
285     */
286    public function isTwoFactorLockedOut(): bool
287    {
288        return $this->hasExceededLoginAttempts();
289    }
290
291    /**
292     * Sets loggedIn to true if the 2FA-auth was successful and saves the login to session.
293     *
294     * setSuccess() clears the failed-attempt counter, so a completed second factor
295     * is the only thing that releases the lockout early.
296     */
297    public function twoFactorSuccess(): bool
298    {
299        $this->setLoggedIn(true);
300        $this->updateSessionId(true);
301        $this->saveToSession();
302        $this->setSuccess(true);
303
304        return true;
305    }
306
307    /**
308     * Sets loggedIn to false and deletes the login from session.
309     */
310    public function setLoggedIn(bool $loggedIn): void
311    {
312        $this->loggedIn = $loggedIn;
313    }
314
315    /**
316     * Returns false if the CurrentUser object stored in the session is valid and not timed out.
317     * There are two parameters for session timeouts: $this->sessionTimeout and $this->sessionIdTimeout.
318     */
319    public function sessionIsTimedOut(): bool
320    {
321        return $this->sessionTimeout <= $this->sessionAge();
322    }
323
324    /**
325     * Returns false if the session-ID is not timed out.
326     */
327    public function sessionIdIsTimedOut(): bool
328    {
329        return $this->sessionIdTimeout <= $this->sessionAge();
330    }
331
332    /**
333     * Returns the age of the current session-ID in minutes.
334     */
335    public function sessionAge(): float
336    {
337        if (!$this->sessionWrapper->has(self::SESSION_ID_TIMESTAMP)) {
338            return 0;
339        }
340
341        $requestTime = (int) Request::createFromGlobals()->server->get('REQUEST_TIME');
342        $sessionTimestamp = (int) $this->sessionWrapper->get(self::SESSION_ID_TIMESTAMP);
343        return ($requestTime - $sessionTimestamp) / 60;
344    }
345
346    /**
347     * Returns an associative array with session information stored
348     * in the user table. The array has the following keys:
349     * session_id, session_timestamp and ip.
350     *
351     * @return array<array-key, mixed>
352     */
353    public function getSessionInfo(): array
354    {
355        $select = sprintf('
356            SELECT
357                session_id,
358                session_timestamp,
359                ip,
360                success
361            FROM
362                %sfaquser
363            WHERE
364                user_id = %d', Database::getTablePrefix(), $this->getUserId());
365
366        $res = $this->configuration->getDb()->query($select);
367        if (!$res || $this->configuration->getDb()->numRows($res) !== 1) {
368            return [];
369        }
370
371        $sessionInfo = $this->configuration->getDb()->fetchArray($res);
372
373        return is_array($sessionInfo) ? $sessionInfo : [];
374    }
375
376    /**
377     * Updates the session-ID, does not care about time-outs.
378     * Store session information in the user table: session_id,
379     * session_timestamp and ip.
380     * Optionally, it should update the 'last login' time.
381     * Returns true to success, otherwise false.
382     *
383     * @param bool $updateLastLogin Update the last login time?
384     */
385    public function updateSessionId(bool $updateLastLogin = false): bool
386    {
387        // renew the session-ID; API and CLI logins run without an active PHP session
388        $oldSessionId = session_id();
389        if (session_status() === PHP_SESSION_ACTIVE && session_regenerate_id(true)) {
390            $sessionPath = (string) session_save_path();
391            if (str_contains($sessionPath, ';')) {
392                $sessionPath = substr($sessionPath, (int) strpos($sessionPath, needle: ';') + 1);
393            }
394
395            $sessionFilename = $sessionPath . '/sess_' . (string) $oldSessionId;
396            if (file_exists($sessionFilename)) {
397                unlink($sessionFilename);
398            }
399        }
400
401        // store session-ID age
402        $this->sessionWrapper->set(
403            self::SESSION_ID_TIMESTAMP,
404            Request::createFromGlobals()->server->get('REQUEST_TIME'),
405        );
406
407        $requestTime = (int) Request::createFromGlobals()->server->get('REQUEST_TIME');
408
409        // save session information in the user table
410        $update = sprintf(
411            "
412            UPDATE
413                %sfaquser
414            SET
415                session_id = '%s',
416                session_timestamp = %d,
417                %s
418                ip = '%s'
419            WHERE
420                user_id = %d",
421            Database::getTablePrefix(),
422            session_id(),
423            $requestTime,
424            $updateLastLogin ? "last_login = '" . date(format: 'YmdHis', timestamp: $requestTime) . "'," : '',
425            Request::createFromGlobals()->getClientIp(),
426            $this->getUserId(),
427        );
428
429        $res = $this->configuration->getDb()->query($update);
430        if (!$res) {
431            $this->errors[] = $this->configuration->getDb()->error();
432
433            return false;
434        }
435
436        return true;
437    }
438
439    /**
440     * Saves the CurrentUser into the session. This method
441     * may be called after a successful login.
442     */
443    public function saveToSession(): void
444    {
445        $this->sessionWrapper->set(self::SESSION_CURRENT_USER, $this->getUserId());
446    }
447
448    /**
449     * Deletes the CurrentUser from the session. The user
450     * will be logged out. Return true to success, otherwise false.
451     */
452    public function deleteFromSession(bool $deleteCookie = false): bool
453    {
454        // delete CurrentUser object from session
455        $this->sessionWrapper->remove(self::SESSION_CURRENT_USER);
456
457        // log CurrentUser out
458        $this->setLoggedIn(false);
459
460        // delete session-ID
461        $update = sprintf(
462            '
463            UPDATE
464                %sfaquser
465            SET
466                session_id = NULL
467                %s
468            WHERE
469                user_id = %d',
470            Database::getTablePrefix(),
471            $deleteCookie ? ', remember_me = NULL' : '',
472            $this->getUserId(),
473        );
474
475        $res = $this->configuration->getDb()->query($update);
476
477        if (!$res) {
478            $this->errors[] = $this->configuration->getDb()->error();
479
480            return false;
481        }
482
483        if ($deleteCookie) {
484            $this->userSession->setCookie(UserSession::COOKIE_NAME_REMEMBER_ME, '');
485        }
486
487        // @todo Check if session_destroy() is really needed here
488        //session_destroy();
489
490        return true;
491    }
492
493    /**
494     * Sets the number of minutes when the current user stored in
495     * the session gets invalid.
496     *
497     * @param int $timeout Timeout
498     */
499    public function setSessionTimeout(int $timeout): void
500    {
501        $this->sessionTimeout = abs($timeout);
502    }
503
504    /**
505     * Enables to "remember me" decision.
506     */
507    public function enableRememberMe(): void
508    {
509        $this->rememberMe = true;
510    }
511
512    /**
513     * Sets the auth container
514     */
515    #[\Override]
516    public function setAuthSource(string $authSource): bool
517    {
518        $update = sprintf(
519            "UPDATE %sfaquser SET auth_source = '%s' WHERE user_id = %d",
520            Database::getTablePrefix(),
521            $this->configuration->getDb()->escape($authSource),
522            $this->getUserId(),
523        );
524
525        return (bool) $this->configuration->getDb()->query($update);
526    }
527
528    /**
529     * Issues the remember-me cookie and stores its token in the database.
530     *
531     * This must only be called once authentication is fully complete. For accounts with
532     * two-factor authentication that means after the second factor has been verified: the
533     * remember-me token is a password-equivalent credential that grants a cookie-based login
534     * via getFromCookie(), so issuing it before 2FA is completed would let an attacker who
535     * only holds the password replay the cookie and bypass 2FA entirely.
536     */
537    public function issueRememberMeCookie(): void
538    {
539        // The remember-me cookie is a password-bypassing credential, so it must be an
540        // unpredictable CSPRNG value (not derived from the session id) and stored hashed
541        // at rest so a database read cannot yield a usable cookie.
542        $rememberMeToken = bin2hex(random_bytes(32));
543        $this->setRememberMe(hash('sha256', $rememberMeToken));
544        $this->userSession->setCookie(
545            UserSession::COOKIE_NAME_REMEMBER_ME,
546            $rememberMeToken,
547            time() + self::PMF_REMEMBER_ME_EXPIRED_TIME,
548        );
549    }
550
551    /**
552     * Saves remember me token in the database.
553     */
554    public function setRememberMe(string $rememberMe): bool
555    {
556        $update = sprintf(
557            "UPDATE %sfaquser SET remember_me = '%s' WHERE user_id = %d",
558            Database::getTablePrefix(),
559            $this->configuration->getDb()->escape($rememberMe),
560            $this->getUserId(),
561        );
562
563        return (bool) $this->configuration->getDb()->query($update);
564    }
565
566    /**
567     * Sets login success/failure.
568     */
569    public function setSuccess(bool $success): bool
570    {
571        $loginState = (int) $success;
572        $this->loginAttempts = 0;
573
574        $update = sprintf(
575            '
576            UPDATE
577                %sfaquser
578            SET
579                success = %d,
580                login_attempts = %d
581            WHERE
582                user_id = %d',
583            Database::getTablePrefix(),
584            $loginState,
585            $this->loginAttempts,
586            $this->getUserId(),
587        );
588
589        return (bool) $this->configuration->getDb()->query($update);
590    }
591
592    /**
593     * @param array{refresh_token: string, access_token: string, code_verifier: string, jwt: mixed} $token
594     * @throws \JsonException
595     */
596    public function setTokenData(#[\SensitiveParameter] array $token): bool
597    {
598        $db = $this->configuration->getDb();
599        $update = sprintf(
600            "
601            UPDATE
602                %sfaquser
603            SET
604                refresh_token = '%s',
605                access_token = '%s',
606                code_verifier = '%s',
607                jwt = '%s'
608            WHERE
609                user_id = %d",
610            Database::getTablePrefix(),
611            $db->escape($token['refresh_token']),
612            $db->escape($token['access_token']),
613            $db->escape($token['code_verifier']),
614            $db->escape(json_encode($token['jwt'], JSON_THROW_ON_ERROR)),
615            $this->getUserId(),
616        );
617
618        return (bool) $db->query($update);
619    }
620
621    /**
622     * Sets IP and session timestamp plus lockout time, a success flag to
623     * false.
624     */
625    protected function setLoginAttempt(): mixed
626    {
627        ++$this->loginAttempts;
628
629        $update = sprintf(
630            "
631            UPDATE
632                %sfaquser
633            SET
634                session_timestamp ='%s',
635                ip = '%s',
636                success = 0,
637                login_attempts = login_attempts + 1
638            WHERE
639                user_id = %d",
640            Database::getTablePrefix(),
641            (int) Request::createFromGlobals()->server->get('REQUEST_TIME'),
642            Request::createFromGlobals()->getClientIp(),
643            $this->getUserId(),
644        );
645
646        return $this->configuration->getDb()->query($update);
647    }
648
649    /**
650     * Checks whether the account is locked out after too many recent failed logins.
651     * Deliberately independent of the client IP: an attacker rotating IPs must not
652     * be able to keep guessing a single account's password.
653     */
654    protected function isFailedLastLoginAttempt(): bool
655    {
656        return $this->hasExceededLoginAttempts();
657    }
658
659    /**
660     * Checks whether the account has burned through its failed-attempt budget
661     * within the lockout window.
662     */
663    private function hasExceededLoginAttempts(): bool
664    {
665        $select = sprintf(
666            "
667            SELECT
668                session_timestamp,
669                success,
670                login_attempts
671            FROM
672                %sfaquser
673            WHERE
674                user_id = %d
675            AND
676                ('%d' - session_timestamp) <= %d
677            AND
678                success = 0
679            AND
680                login_attempts > %d",
681            Database::getTablePrefix(),
682            $this->getUserId(),
683            (int) Request::createFromGlobals()->server->get('REQUEST_TIME'),
684            $this->lockoutTime,
685            self::MAX_FAILED_LOGIN_ATTEMPTS,
686        );
687
688        /** @var mixed $result */
689        $result = $this->configuration->getDb()->query($select);
690        return $this->configuration->getDb()->numRows($result) !== 0;
691    }
692
693    /**
694     * Sorts the auth container array.
695     * @param array<string, Auth&AuthDriverInterface> $authContainer
696     * @return array<string, Auth&AuthDriverInterface>
697     */
698    protected function sortAuthContainer(array $authContainer): array
699    {
700        uksort($authContainer, static function ($first, $second): int {
701            if ($first === 'local') {
702                return 1;
703            }
704
705            if ($second === 'local') {
706                return -1;
707            }
708
709            return 0;
710        });
711
712        return $authContainer;
713    }
714}

From phpMyFAQ\User\CurrentUserAccountStateTrait

25trait CurrentUserAccountStateTrait
26{
27    /**
28     * Returns the user ID of the composing user class.
29     */
30    abstract public function getUserId(): int;
31
32    /**
33     * Returns the configuration of the composing user class.
34     */
35    abstract protected function accountStateConfiguration(): Configuration;
36
37    /**
38     * Returns true if the user is a local user, otherwise false.
39     */
40    public function isLocalUser(): bool
41    {
42        $query = sprintf(
43            "SELECT auth_source FROM %sfaquser WHERE auth_source = 'local' AND user_id = %d",
44            Database::getTablePrefix(),
45            $this->getUserId(),
46        );
47
48        $db = $this->accountStateConfiguration()->getDb();
49        $result = $db->query($query);
50
51        return (bool) $db->fetchRow($result);
52    }
53
54    public function isBlocked(): bool
55    {
56        $query = sprintf(
57            'SELECT account_status FROM %sfaquser WHERE user_id = %d',
58            Database::getTablePrefix(),
59            $this->getUserId(),
60        );
61
62        $db = $this->accountStateConfiguration()->getDb();
63        $result = $db->query($query);
64        $row = $db->fetchArray($result);
65
66        return ($row['account_status'] ?? null) === 'blocked';
67    }
68}

From phpMyFAQ\User\CurrentUserSessionLookupTrait

28trait CurrentUserSessionLookupTrait
29{
30    /**
31     * Returns the current user object from cookie or session
32     *
33     * @throws Exception
34     */
35    public static function getCurrentUser(Configuration $configuration): CurrentUser
36    {
37        $user = self::getFromCookie($configuration);
38
39        if (!$user instanceof CurrentUser) {
40            $user = self::getFromSession($configuration);
41        }
42
43        if (!$user instanceof CurrentUser) {
44            return new CurrentUser($configuration);
45        }
46
47        $user->setLoggedIn(true);
48        return $user;
49    }
50
51    /**
52     * Returns the current user ID and group IDs as an array, default values are -1
53     *
54     * @return array{0: int, 1: int[]}
55     */
56    public static function getCurrentUserGroupId(?CurrentUser $user = null): array
57    {
58        if ($user === null) {
59            return [-1, [-1]];
60        }
61
62        $currentUser = $user->getUserId();
63        $currentGroups = [-1];
64        if ($user->perm instanceof MediumPermission) {
65            $currentGroups = $user->perm->getUserGroups($currentUser);
66        }
67
68        if ($currentGroups === []) {
69            $currentGroups = [-1];
70        }
71
72        return [$currentUser, $currentGroups];
73    }
74
75    /**
76     * This static method returns a valid CurrentUser object if there is one
77     * in the session that is not timed out. The session-ID is updated if
78     * necessary. The CurrentUser will be removed from the session if it is
79     * timed out. If there is no valid CurrentUser in the session or the
80     * session is timed out, null will be returned. If the session data is
81     * correct, but there is no user found in the user table, false will be
82     * returned. On success, a valid CurrentUser object is returned.
83     */
84    public static function getFromSession(Configuration $configuration): ?CurrentUser
85    {
86        $sessionWrapper = new SessionWrapper();
87        // there is no valid user object in the session
88        if (
89            !$sessionWrapper->has(CurrentUser::SESSION_CURRENT_USER)
90            || !$sessionWrapper->has(CurrentUser::SESSION_ID_TIMESTAMP)
91        ) {
92            return null;
93        }
94
95        // create a new CurrentUser object
96        $user = new CurrentUser($configuration);
97        $user->getUserById((int) $sessionWrapper->get(CurrentUser::SESSION_CURRENT_USER));
98
99        // user object is timed out
100        if ($user->sessionIsTimedOut()) {
101            $user->deleteFromSession();
102            $user->errors[] = 'Session timed out.';
103
104            return null;
105        }
106
107        // session-id isn't found in the user table
108        $sessionInfo = $user->getSessionInfo();
109        $sessionId = $sessionInfo['session_id'] ?? '';
110        if ($sessionId === '' || $sessionId !== session_id()) {
111            return null;
112        }
113
114        // check ip
115        if (
116            (bool) $configuration->get('security.ipCheck')
117            && $sessionInfo['ip'] !== Request::createFromGlobals()->getClientIp()
118        ) {
119            return null;
120        }
121
122        // session-id needs to be updated
123        if ($user->sessionIdIsTimedOut()) {
124            $user->updateSessionId();
125        }
126
127        // user is now logged in
128        $user->loggedIn = true;
129        // save the current user to the session and return the instance
130        $user->saveToSession();
131
132        return $user;
133    }
134
135    /**
136     * This static method returns a valid CurrentUser object if there is one
137     * in the cookie that is not timed out. The session-ID is updated then.
138     * The CurrentUser will be removed from the session if it is
139     * timed out. If there is no valid CurrentUser in the cookie or the
140     * cookie is timed out, null will be returned. If the cookie is correct,
141     * but there is no user found in the user table, false will be returned.
142     * On success, a valid CurrentUser object is returned.
143     *
144     * @throws Exception
145     */
146    public static function getFromCookie(Configuration $configuration): ?CurrentUser
147    {
148        $request = Request::createFromGlobals();
149        if ($request->cookies->get(UserSession::COOKIE_NAME_REMEMBER_ME) === null) {
150            return null;
151        }
152
153        // create a new CurrentUser object
154        $user = new CurrentUser($configuration);
155        $user->getUserByCookie((string) $request->cookies->get(UserSession::COOKIE_NAME_REMEMBER_ME, ''));
156
157        if (-1 === $user->getUserId()) {
158            return null;
159        }
160
161        // sessionId needs to be updated
162        $user->updateSessionId(true);
163        // user is now logged in
164        $user->loggedIn = true;
165        // save current user to session and return the instance
166        $user->saveToSession();
167
168        return $user;
169    }
170}

Inherited from phpMyFAQ\User

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    }
212    public function addPerm(PermissionInterface $permission): bool
213    {
214        $this->perm = $permission;
215        return true;
216    }
221    public function getAuthSource(string $key): ?string
222    {
223        return $this->authData['authSource'][$key] ?? null;
224    }
226    public function getUserAuthSource(): string
227    {
228        return $this->authSource;
229    }
234    public function getAuthData(string $key): mixed
235    {
236        return $this->authData[$key] ?? null;
237    }
245    public function addAuth(Auth&AuthDriverInterface $authDriver, string $name): bool
246    {
247        $this->authContainer[$name] = $authDriver;
248        return true;
249    }
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    }
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    }
318    public function checkDisplayName(string $name): bool
319    {
320        return $name === $this->userData()->fetch('display_name', $name);
321    }
326    public function checkMailAddress(string $name): bool
327    {
328        return $name === $this->userData()->fetch('email', $name);
329    }
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    }
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    }
441    private function getTenantQuotaEnforcer(): TenantQuotaEnforcer
442    {
443        return $this->tenantQuotaEnforcer ??= TenantQuotaEnforcer::createFromDatabaseDriver(
444            $this->configuration->getDb(),
445        );
446    }
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    }
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    }
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    }
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    }
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    }
642    public function getAuthContainer(): array
643    {
644        return $this->authContainer;
645    }
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    }
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    }
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    }
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    }
769    public function getLogin(): string
770    {
771        return $this->login;
772    }
777    public function getUserIdByEmail(string $email): int
778    {
779        $userData = $this->userData()->fetchAll('email', $email);
780
781        return (int) ($userData['user_id'] ?? 0);
782    }
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    }
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    }
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    }
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    }
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    }
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    }
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    }
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    }
991    public function isSuperAdmin(): bool
992    {
993        return $this->isSuperAdmin;
994    }
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    }
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    }
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    }
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    }
1076    private function fetchRowArray(mixed $result): array
1077    {
1078        $row = $this->configuration->getDb()->fetchArray($result);
1079
1080        return is_array($row) ? $row : [];
1081    }
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    }
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    }
1119    private function isEmailAddress(string $string): bool
1120    {
1121        return filter_var($string, FILTER_VALIDATE_EMAIL) !== false;
1122    }