Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.50% covered (success)
88.50%
177 / 200
80.77% covered (success)
80.77%
21 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
CurrentUser
88.50% covered (success)
88.50%
177 / 200
80.77% covered (success)
80.77%
21 / 26
73.83
0.00% covered (danger)
0.00%
0 / 1
 accountStateConfiguration
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 login
80.77% covered (success)
80.77%
42 / 52
0.00% covered (danger)
0.00%
0 / 1
32.18
 isLoggedIn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 twoFactorFailure
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isTwoFactorLockedOut
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 twoFactorSuccess
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 setLoggedIn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sessionIsTimedOut
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sessionIdIsTimedOut
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sessionAge
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getSessionInfo
85.71% covered (success)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 updateSessionId
85.71% covered (success)
85.71%
24 / 28
0.00% covered (danger)
0.00%
0 / 1
7.14
 saveToSession
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deleteFromSession
81.25% covered (success)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
4.11
 setSessionTimeout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 enableRememberMe
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setAuthSource
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 issueRememberMeCookie
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 setRememberMe
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 setSuccess
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 setTokenData
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 setLoginAttempt
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 isFailedLastLoginAttempt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasExceededLoginAttempts
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 sortAuthContainer
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
5.20
1<?php
2
3/**
4 * Manages the authentication process using PHP sessions.
5 * The CurrentUser class is an extension of the User class.
6 * It provides methods to manage user authentication using multiple database accesses.
7 * There are three ways of making a new current user object, using the login(), getFromSession(), getFromCookie() or
8 * manually. login(), getFromSession() and getFromCookie() may be combined.
9 *
10 * This Source Code Form is subject to the terms of the Mozilla Public License,
11 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
12 * obtain one at https://mozilla.org/MPL/2.0/.
13 *
14 * @package   phpMyFAQ
15 * @author    Lars Tiedemann <php@larstiedemann.de>
16 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
17 * @copyright 2005-2026 phpMyFAQ Team
18 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
19 * @link      https://www.phpmyfaq.de
20 * @since     2005-09-28
21 */
22
23declare(strict_types=1);
24
25namespace phpMyFAQ\User;
26
27use phpMyFAQ\Auth;
28use phpMyFAQ\Auth\AuthDriverInterface;
29use phpMyFAQ\Auth\AuthException;
30use phpMyFAQ\Configuration;
31use phpMyFAQ\Core\Exception;
32use phpMyFAQ\Database;
33use phpMyFAQ\Filter;
34use phpMyFAQ\Session\SessionWrapper;
35use phpMyFAQ\User;
36use SensitiveParameter;
37use Symfony\Component\HttpFoundation\Request;
38
39/**
40 * Class CurrentUser
41 *
42 * @package   phpMyFAQ
43 * @author    Lars Tiedemann <php@larstiedemann.de>
44 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
45 * @copyright 2005-2026 phpMyFAQ Team
46 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
47 * @link      https://www.phpmyfaq.de
48 * @since     2005-09-28
49 */
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}