Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
57.14% covered (warning)
57.14%
36 / 63
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserAuthentication
57.14% covered (warning)
57.14%
36 / 63
72.73% covered (warning)
72.73%
8 / 11
100.85
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isRememberMe
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setRememberMe
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasTwoFactorAuthentication
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setTwoFactorAuth
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 authenticate
50.00% covered (danger)
50.00%
17 / 34
0.00% covered (danger)
0.00%
0 / 1
22.50
 hasExhaustedFailedLoginBudget
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 recordFailedLogin
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 failedLoginKey
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 authenticateLdap
27.27% covered (danger)
27.27%
3 / 11
0.00% covered (danger)
0.00%
0 / 1
19.85
 authenticateSso
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
1<?php
2
3/**
4 * Class for User Authentication handling.
5 *
6 * This class handles all operations around creating, saving and getting the secret
7 * for a CurrentUser for two-factor-authentication. It also validates given tokens in
8 * comparison to a given secret and returns a QR-code for transmitting a secret to
9 * the authenticator-app.
10 *
11 * This Source Code Form is subject to the terms of the Mozilla Public License,
12 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
13 * obtain one at http://mozilla.org/MPL/2.0/.
14 *
15 * @package   phpMyFAQ
16 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
17 * @copyright 2023-2026 phpMyFAQ Team
18 * @license   http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
19 * @link      https://www.phpmyfaq.de
20 * @since     2023-03-11
21 */
22
23declare(strict_types=1);
24
25namespace phpMyFAQ\User;
26
27use phpMyFAQ\Auth\AuthException;
28use phpMyFAQ\Auth\AuthLdap;
29use phpMyFAQ\Auth\AuthSso;
30use phpMyFAQ\Configuration;
31use phpMyFAQ\Http\RateLimiter;
32use phpMyFAQ\Translation;
33use phpMyFAQ\User;
34use SensitiveParameter;
35use Symfony\Component\HttpFoundation\Request;
36
37class UserAuthentication
38{
39    /**
40     * Failed login attempts one client IP may make within the failure window
41     * before further attempts are rejected, across all accounts.
42     */
43    public const int MAX_FAILED_LOGINS_PER_IP = 15;
44
45    /**
46     * Sliding window for the per-IP failed login budget, in seconds.
47     */
48    public const int FAILED_LOGIN_WINDOW = 300;
49
50    private bool $rememberMe = false;
51
52    private bool $twoFactorAuth = false;
53
54    public function __construct(
55        private readonly Configuration $configuration,
56        private readonly CurrentUser $currentUser,
57        private readonly ?RateLimiter $rateLimiter = null,
58    ) {
59    }
60
61    public function isRememberMe(): bool
62    {
63        return $this->rememberMe;
64    }
65
66    public function setRememberMe(bool $rememberMe): void
67    {
68        $this->rememberMe = $rememberMe;
69    }
70
71    public function hasTwoFactorAuthentication(): bool
72    {
73        return $this->twoFactorAuth;
74    }
75
76    public function setTwoFactorAuth(bool $twoFactorAuth): void
77    {
78        $this->twoFactorAuth = $twoFactorAuth;
79    }
80
81    /**
82     * Authenticates a user with a given username and password against
83     * LDAP, SSO, or local database.
84     *
85     * @throws UserException
86     */
87    public function authenticate(string $username, #[SensitiveParameter] string $password): CurrentUser
88    {
89        if ($this->hasExhaustedFailedLoginBudget()) {
90            // Reject before any password check runs: this client IP produced too
91            // many failed logins recently, across all accounts.
92            throw new UserException(User::ERROR_USER_TOO_MANY_FAILED_LOGINS);
93        }
94
95        if ($this->isRememberMe()) {
96            $this->currentUser->enableRememberMe();
97        }
98
99        $this->authenticateLdap();
100        $this->authenticateSso();
101
102        try {
103            if (!$this->currentUser->login($username, $password)) {
104                $this->recordFailedLogin();
105                $authFailMessage = Translation::get(key: 'ad_auth_fail');
106                throw new UserException(is_string($authFailMessage) ? $authFailMessage : 'Authentication failed');
107            }
108
109            if ($this->currentUser->getUserData('twofactor_enabled')) {
110                $this->setTwoFactorAuth(true);
111                $this->currentUser->setLoggedIn(false);
112                return $this->currentUser;
113            }
114
115            if ($this->currentUser->getStatus() !== 'blocked') {
116                $this->currentUser->setLoggedIn(true);
117                return $this->currentUser;
118            }
119
120            $this->currentUser->setLoggedIn(false);
121            throw new UserException(
122                (
123                    ($authFailMessage = Translation::getString(key: 'ad_auth_fail')) !== ''
124                        ? $authFailMessage
125                        : 'Authentication failed'
126                )
127                . ' ('
128                . $username
129                . ')',
130            );
131        } catch (AuthException $authException) {
132            $this->recordFailedLogin();
133            throw new UserException($authException->getMessage());
134        } catch (UserException $userException) {
135            $this->recordFailedLogin();
136            throw $userException;
137        }
138
139        return $this->currentUser;
140    }
141
142    /**
143     * The per-IP failure budget stops password spraying from a single client
144     * across many accounts, which the per-account lockout cannot see.
145     */
146    private function hasExhaustedFailedLoginBudget(): bool
147    {
148        $failedLoginKey = $this->failedLoginKey();
149        if (!$this->rateLimiter instanceof RateLimiter || $failedLoginKey === null) {
150            return false;
151        }
152
153        return !$this->rateLimiter->peek($failedLoginKey, self::MAX_FAILED_LOGINS_PER_IP, self::FAILED_LOGIN_WINDOW);
154    }
155
156    private function recordFailedLogin(): void
157    {
158        $failedLoginKey = $this->failedLoginKey();
159        if ($failedLoginKey === null) {
160            return;
161        }
162
163        $this->rateLimiter?->check($failedLoginKey, self::MAX_FAILED_LOGINS_PER_IP, self::FAILED_LOGIN_WINDOW);
164    }
165
166    /**
167     * Null when no client IP is available (CLI scripts, test runs): a per-IP
168     * budget without an IP would lump unrelated clients together.
169     */
170    private function failedLoginKey(): ?string
171    {
172        $clientIp = Request::createFromGlobals()->getClientIp();
173
174        return $clientIp === null ? null : 'login-failures-' . $clientIp;
175    }
176
177    private function authenticateLdap(): void
178    {
179        $ldapEnabled = filter_var($this->configuration->get('ldap.ldapSupport'), FILTER_VALIDATE_BOOLEAN);
180        if (!$ldapEnabled || !function_exists('ldap_connect')) {
181            return;
182        }
183
184        if ($this->configuration->getLdapServer() === [] || $this->configuration->getLdapConfig() === []) {
185            return;
186        }
187
188        try {
189            $authLdap = new AuthLdap($this->configuration);
190            $this->currentUser->addAuth($authLdap, 'ldap');
191        } catch (\Throwable $exception) {
192            // LDAP initialization failed (e.g. server unreachable) - log and continue with local auth
193            $this->configuration
194                ->getLogger()
195                ->error('LDAP authentication initialization failed: ' . $exception->getMessage());
196        }
197    }
198
199    private function authenticateSso(): void
200    {
201        if ($this->configuration->get(item: 'security.ssoSupport')) {
202            $authSso = new AuthSso($this->configuration);
203            $this->currentUser->addAuth($authSso, 'sso');
204        }
205    }
206}