Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.58% covered (success)
98.58%
139 / 141
85.71% covered (success)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthenticationController
98.58% covered (success)
98.58%
139 / 141
85.71% covered (success)
85.71%
6 / 7
36
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
 login
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
4
 forgotPassword
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 logout
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
9
 authenticate
94.29% covered (success)
94.29%
33 / 35
0.00% covered (danger)
0.00%
0 / 1
10.02
 token
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 check
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Authentication Controller to handle login, logout, and password reset
7 *
8 * This Source Code Form is subject to the terms of the Mozilla Public License,
9 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
10 * obtain one at https://mozilla.org/MPL/2.0/.
11 *
12 * @package   phpMyFAQ
13 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
14 * @copyright 2012-2026 phpMyFAQ Team
15 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
16 * @link      https://www.phpmyfaq.de
17 * @since     2012-02-12
18 */
19
20namespace phpMyFAQ\Controller\Frontend;
21
22use phpMyFAQ\Core\Exception;
23use phpMyFAQ\Filter;
24use phpMyFAQ\Session\Token;
25use phpMyFAQ\Translation;
26use phpMyFAQ\User\CurrentUser;
27use phpMyFAQ\User\TwoFactor;
28use phpMyFAQ\User\UserAuthentication;
29use phpMyFAQ\User\UserException;
30use phpMyFAQ\User\UserSession;
31use Symfony\Component\HttpFoundation\RedirectResponse;
32use Symfony\Component\HttpFoundation\Request;
33use Symfony\Component\HttpFoundation\Response;
34use Symfony\Component\Routing\Attribute\Route;
35use Twig\Error\LoaderError;
36
37final class AuthenticationController extends AbstractFrontController
38{
39    public function __construct(
40        private readonly UserSession $userSession,
41        private readonly CurrentUser $currentUserService,
42        private readonly TwoFactor $twoFactor,
43    ) {
44        parent::__construct();
45    }
46
47    /**
48     * @throws Exception
49     * @throws LoaderError
50     * @throws \Exception
51     */
52    #[Route(path: '/login', name: 'public.auth.login', methods: ['GET'])]
53    public function login(Request $request): Response
54    {
55        $this->userSession->setCurrentUser($this->currentUser);
56        $this->userSession->userTracking('login', 0);
57
58        // Redirect to authenticate if SSO is enabled and the user is already authenticated
59        if (
60            (bool) $this->configuration->get(item: 'security.ssoSupport')
61            && $request->server->get(key: 'REMOTE_USER') !== null
62        ) {
63            return new RedirectResponse(url: './authenticate');
64        }
65
66        $errorMessages = $this->session->getFlashBag()->get('error');
67        $errorMessage = count($errorMessages) > 0 ? $errorMessages[0] : null;
68
69        return $this->render('login.twig', [
70            ...$this->getHeader($request),
71            'title' => sprintf(
72                '%s - %s',
73                Translation::getString(key: 'msgLoginUser'),
74                $this->configuration->getTitle(),
75            ),
76            'loginHeader' => Translation::get(key: 'msgLoginUser'),
77            'errorMessage' => $errorMessage,
78            'writeLoginPath' => $this->configuration->getDefaultUrl(),
79            'login' => Translation::get(key: 'ad_auth_ok'),
80            'username' => Translation::get(key: 'ad_auth_user'),
81            'password' => Translation::get(key: 'ad_auth_passwd'),
82            'rememberMe' => Translation::get(key: 'rememberMe'),
83            'msgTwofactorEnabled' => Translation::get(key: 'msgTwofactorEnabled'),
84            'msgTwofactorTokenModelTitle' => Translation::get(key: 'msgTwofactorTokenModelTitle'),
85            'msgEnterTwofactorToken' => Translation::get(key: 'msgEnterTwofactorToken'),
86            'msgTwofactorCheck' => Translation::get(key: 'msgTwofactorCheck'),
87            'userid' => $this->currentUser->getUserId(),
88            'enableRegistration' => $this->configuration->get('security.enableRegistration'),
89            'registerUser' => Translation::get(key: 'msgRegistration'),
90            'useSignInWithMicrosoft' => $this->configuration->isSignInWithMicrosoftActive(),
91            'useSignInWithKeycloak' => $this->configuration->isSignInWithKeycloakActive(),
92            'isWebAuthnEnabled' => $this->configuration->get('security.enableWebAuthnSupport'),
93        ]);
94    }
95
96    /**
97     * @throws Exception
98     * @throws LoaderError
99     * @throws \Exception
100     */
101    #[Route(path: '/forgot-password', name: 'public.forgot-password', methods: ['GET', 'POST'])]
102    public function forgotPassword(Request $request): Response
103    {
104        $this->userSession->setCurrentUser($this->currentUser);
105        $this->userSession->userTracking('forgot_password', 0);
106
107        return $this->render('password.twig', [
108            ...$this->getHeader($request),
109            'lang' => $this->configuration->getLanguage()->getLanguage(),
110            'username' => Translation::get(key: 'ad_auth_user'),
111            'password' => Translation::get(key: 'ad_auth_passwd'),
112        ]);
113    }
114
115    /**
116     * @throws \Exception
117     */
118    #[Route(path: '/logout', name: 'public.auth.logout', methods: ['GET'])]
119    public function logout(Request $request): RedirectResponse
120    {
121        $csrfToken = Filter::filterVar($request->query->get('csrf'), FILTER_SANITIZE_SPECIAL_CHARS, '');
122
123        $redirectResponse = new RedirectResponse(url: $this->configuration->getDefaultUrl());
124
125        if (!Token::getInstance($this->session)->verifyToken('logout', $csrfToken)) {
126            $this->session->getFlashBag()->add('error', 'CSRF Problem detected: ' . $csrfToken);
127            return $redirectResponse;
128        }
129
130        if (!$this->currentUser->isLoggedIn()) {
131            return $redirectResponse;
132        }
133
134        $this->currentUser->deleteFromSession(true);
135
136        // Add a success message
137        $this->session->getFlashBag()->add('success', Translation::get('ad_logout'));
138
139        // SSO Logout
140        $ssoLogout = (string) ($this->configuration->get('security.ssoLogoutRedirect') ?? '');
141        if ((bool) $this->configuration->get('security.ssoSupport') && $ssoLogout !== '') {
142            $redirectResponse->isRedirect($ssoLogout);
143            return $redirectResponse;
144        }
145
146        // Microsoft Azure Logout
147        if (
148            $this->configuration->isSignInWithMicrosoftActive()
149            && $this->currentUser->getUserAuthSource() === 'azure'
150        ) {
151            return new RedirectResponse($this->configuration->getDefaultUrl() . 'auth/azure/logout');
152        }
153
154        if (
155            $this->configuration->isSignInWithKeycloakActive()
156            && $this->currentUser->getUserAuthSource() === 'keycloak'
157        ) {
158            return new RedirectResponse($this->configuration->getDefaultUrl() . 'auth/keycloak/logout');
159        }
160
161        return $redirectResponse;
162    }
163
164    /**
165     * Handles user authentication (login form submission)
166     *
167     * @throws \Exception
168     */
169    #[Route(path: '/authenticate', name: 'public.auth.authenticate', methods: ['POST'])]
170    public function authenticate(Request $request): RedirectResponse
171    {
172        if ($this->currentUser->isLoggedIn()) {
173            return new RedirectResponse(url: './');
174        }
175
176        $username = Filter::filterVar($request->request->get('faqusername'), FILTER_SANITIZE_SPECIAL_CHARS, '');
177        $password = Filter::filterVar(
178            $request->request->get('faqpassword'),
179            FILTER_SANITIZE_SPECIAL_CHARS,
180            FILTER_FLAG_NO_ENCODE_QUOTES,
181        );
182        $rememberMe = Filter::filterVar($request->request->get('faqrememberme'), FILTER_VALIDATE_BOOLEAN);
183
184        // Set username via SSO
185        if (
186            (bool) $this->configuration->get(item: 'security.ssoSupport')
187            && $request->server->get(key: 'REMOTE_USER') !== null
188        ) {
189            $username = trim((string) $request->server->get(key: 'REMOTE_USER'));
190            $password = '';
191        }
192
193        // Login via local DB or LDAP or SSO
194        if ($username !== '' && ($password !== '' || (bool) $this->configuration->get('security.ssoSupport'))) {
195            $userAuthentication = new UserAuthentication(
196                $this->configuration,
197                $this->currentUser,
198                $this->getRateLimiter(),
199            );
200            $userAuthentication->setRememberMe($rememberMe ?? false);
201            try {
202                $this->currentUser = $userAuthentication->authenticate($username, (string) $password);
203
204                // Check if two-factor authentication is enabled
205                if ($userAuthentication->hasTwoFactorAuthentication()) {
206                    // The failure count is deliberately not reset here: a correct
207                    // password must not buy a fresh budget of token guesses.
208                    if ($this->currentUser->isTwoFactorLockedOut()) {
209                        $this->session->getFlashBag()->add('error', Translation::get('ad_auth_fail'));
210                        return new RedirectResponse('./login');
211                    }
212
213                    // Bind the pending 2FA step to this user, but only after the password was validated
214                    $this->session->set('2fa_pending_user_id', $this->currentUser->getUserId());
215                    // The remember-me cookie must not be issued until the second factor has
216                    // been verified. Carry the request through the token step so check() can
217                    // issue the cookie only after a successful 2FA challenge.
218                    $this->session->set('2fa_pending_remember_me', $userAuthentication->isRememberMe());
219                    return new RedirectResponse(url: './token?user-id=' . $this->currentUser->getUserId());
220                }
221
222                return new RedirectResponse('./');
223            } catch (UserException $e) {
224                // Log the specific reason server-side, but never disclose whether the login
225                // name exists: always show the same generic message to prevent user enumeration.
226                $this->configuration->getLogger()->error('Login-error: ' . $e->getMessage());
227                $this->session->getFlashBag()->add('error', Translation::get('ad_auth_fail'));
228                return new RedirectResponse('./login');
229            }
230        }
231
232        $this->session->getFlashBag()->add('error', Translation::get('ad_auth_fail'));
233        return new RedirectResponse($this->configuration->getDefaultUrl() . 'login');
234    }
235
236    /**
237     * Displays the two-factor authentication page
238     *
239     * @throws Exception
240     * @throws LoaderError
241     * @throws \Exception
242     */
243    #[Route(path: '/token', name: 'public.auth.token', methods: ['GET'])]
244    public function token(Request $request): Response
245    {
246        if ($this->currentUser->isLoggedIn()) {
247            return new RedirectResponse(url: './');
248        }
249
250        $this->userSession->setCurrentUser($this->currentUser);
251        $this->userSession->userTracking('twofactor', 0);
252
253        $userId = (int) Filter::filterVar($request->query->get(key: 'user-id'), FILTER_VALIDATE_INT);
254
255        return $this->render('twofactor.twig', [
256            ...$this->getHeader($request),
257            'title' => sprintf(
258                '%s - %s',
259                Translation::getString(key: 'msgTwofactorEnabled'),
260                $this->configuration->getTitle(),
261            ),
262            'msgTwofactorEnabled' => Translation::get(key: 'msgTwofactorEnabled'),
263            'msgEnterTwofactorToken' => Translation::get(key: 'msgEnterTwofactorToken'),
264            'msgTwofactorCheck' => Translation::get(key: 'msgTwofactorCheck'),
265            'userId' => $userId,
266        ]);
267    }
268
269    /**
270     * Validates the two-factor authentication token
271     *
272     * @throws \Exception
273     */
274    #[Route(path: '/check', name: 'public.auth.check', methods: ['POST'])]
275    public function check(Request $request): RedirectResponse
276    {
277        if ($this->currentUser->isLoggedIn()) {
278            return new RedirectResponse(url: './');
279        }
280
281        $token = Filter::filterVar($request->request->get(key: 'token'), FILTER_SANITIZE_SPECIAL_CHARS, '');
282        $userId = (int) Filter::filterVar($request->request->get(key: 'user-id'), FILTER_VALIDATE_INT);
283
284        if ($userId <= 0) {
285            $this->session->getFlashBag()->add('error', Translation::get('msgTwofactorErrorToken'));
286            return new RedirectResponse('./token?user-id=' . $userId);
287        }
288
289        // The 2FA step is only reachable once the password was validated for exactly this user
290        $pendingUserId = $this->session->get('2fa_pending_user_id');
291        if ($pendingUserId === null || (int) $pendingUserId !== $userId) {
292            return new RedirectResponse('./login');
293        }
294
295        $this->currentUserService->getUserById($userId);
296
297        // The failure count lives on the account, not in the session, so that neither
298        // a fresh session nor another password authentication can clear it.
299        if ($this->currentUserService->isTwoFactorLockedOut()) {
300            $this->session->remove('2fa_pending_user_id');
301            $this->session->remove('2fa_pending_remember_me');
302            return new RedirectResponse('./login');
303        }
304
305        if (strlen((string) $token) === 6) {
306            $result = $this->twoFactor->validateToken($token, $userId);
307
308            if ($result) {
309                $this->session->remove('2fa_pending_user_id');
310                $rememberMe = true === $this->session->get('2fa_pending_remember_me');
311                $this->session->remove('2fa_pending_remember_me');
312                // twoFactorSuccess() clears the counter via setSuccess().
313                $this->currentUserService->twoFactorSuccess();
314                // The second factor is now verified, so the remember-me cookie can safely
315                // be issued for the fully authenticated session.
316                if ($rememberMe) {
317                    $this->currentUserService->issueRememberMeCookie();
318                }
319
320                return new RedirectResponse(url: './');
321            }
322        }
323
324        $this->currentUserService->twoFactorFailure();
325
326        $this->session->getFlashBag()->add('error', Translation::get('msgTwofactorErrorToken'));
327        return new RedirectResponse('./token?user-id=' . $userId);
328    }
329}