Lines 57.14% 36 / 63
Methods 72.72% 8 / 11
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 isRememberMe 100.00% 1 / 1 100.00% 1 / 1 1
 setRememberMe 100.00% 1 / 1 100.00% 1 / 1 1
 hasTwoFactorAuthentication 100.00% 1 / 1 100.00% 1 / 1 1
 setTwoFactorAuth 100.00% 1 / 1 100.00% 1 / 1 1
 authenticate 50.00% 17 / 34 0.00% 0 / 1 22.50
 hasExhaustedFailedLoginBudget 100.00% 4 / 4 100.00% 1 / 1 3
 recordFailedLogin 100.00% 4 / 4 100.00% 1 / 1 2
 failedLoginKey 100.00% 2 / 2 100.00% 1 / 1 2
 authenticateLdap 27.27% 3 / 11 0.00% 0 / 1 19.85
 authenticateSso 33.33% 1 / 3 0.00% 0 / 1 3.19
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}