Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.14% covered (success)
87.14%
122 / 140
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthenticationController
87.14% covered (success)
87.14%
122 / 140
50.00% covered (danger)
50.00%
3 / 6
34.18
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
 authenticate
64.44% covered (warning)
64.44%
29 / 45
0.00% covered (danger)
0.00%
0 / 1
14.49
 login
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
5
 logout
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
6.01
 token
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 check
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
8
1<?php
2
3/**
4 * The Administration Authentication Controller
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2024-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2024-12-28
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Administration;
21
22use phpMyFAQ\Core\Exception;
23use phpMyFAQ\Enums\AdminLogType;
24use phpMyFAQ\Filter;
25use phpMyFAQ\Session\Token;
26use phpMyFAQ\Translation;
27use phpMyFAQ\User\CurrentUser;
28use phpMyFAQ\User\TwoFactor;
29use phpMyFAQ\User\UserAuthentication;
30use phpMyFAQ\User\UserException;
31use Symfony\Component\HttpFoundation\RedirectResponse;
32use Symfony\Component\HttpFoundation\Request;
33use Symfony\Component\HttpFoundation\Response;
34use Symfony\Component\Routing\Attribute\Route;
35
36final class AuthenticationController extends AbstractAdministrationController implements SkipsAuthenticationCheck
37{
38    public function __construct(
39        private readonly CurrentUser $currentUserService,
40        private readonly TwoFactor $twoFactor,
41    ) {
42        parent::__construct();
43    }
44
45    #[Route(path: '/authenticate', name: 'admin.auth.authenticate', methods: ['POST'])]
46    public function authenticate(Request $request): RedirectResponse
47    {
48        if ($this->currentUser->isLoggedIn()) {
49            return new RedirectResponse(url: './');
50        }
51
52        $username = Filter::filterVar($request->request->get(key: 'faqusername'), FILTER_SANITIZE_SPECIAL_CHARS, '');
53        $password = Filter::filterVar(
54            $request->request->get(key: 'faqpassword'),
55            FILTER_SANITIZE_SPECIAL_CHARS,
56            FILTER_FLAG_NO_ENCODE_QUOTES,
57        );
58        $rememberMe = Filter::filterVar($request->request->get(key: 'faqrememberme'), FILTER_VALIDATE_BOOLEAN);
59
60        // Set username via SSO
61        if (
62            (bool) $this->configuration->get(item: 'security.ssoSupport')
63            && $request->server->get(key: 'REMOTE_USER') !== null
64        ) {
65            $username = trim((string) $request->server->get(key: 'REMOTE_USER'));
66            $password = '';
67        }
68
69        // Login via local DB or LDAP or SSO
70        if ($username !== '' && ($password !== '' || (bool) $this->configuration->get(item: 'security.ssoSupport'))) {
71            $userAuthentication = new UserAuthentication(
72                $this->configuration,
73                $this->currentUser,
74                $this->getRateLimiter(),
75            );
76            $userAuthentication->setRememberMe($rememberMe ?? false);
77            try {
78                $this->currentUser = $userAuthentication->authenticate($username, (string) $password);
79                if ($userAuthentication->hasTwoFactorAuthentication()) {
80                    $userId = $this->currentUser->getUserId();
81
82                    // The failure count is deliberately not reset here: a correct
83                    // password must not buy a fresh budget of token guesses.
84                    if ($this->currentUser->isTwoFactorLockedOut()) {
85                        return new RedirectResponse(url: './login');
86                    }
87
88                    $session = $this->session;
89                    $session->set('2fa_pending_user_id', $userId);
90                    // The remember-me cookie must not be issued until the second factor has
91                    // been verified. Carry the request through the token step so check() can
92                    // issue the cookie only after a successful 2FA challenge.
93                    $session->set('2fa_pending_remember_me', $userAuthentication->isRememberMe());
94                    $this->adminLog->log(
95                        $this->currentUser,
96                        AdminLogType::AUTH_LOGIN_SUCCESS->value . ' (2FA required):' . $username,
97                    );
98                    return new RedirectResponse(url: './token?user-id=' . $userId);
99                }
100
101                $this->adminLog->log($this->currentUser, AdminLogType::AUTH_LOGIN_SUCCESS->value . ':' . $username);
102                return new RedirectResponse(url: './');
103            } catch (Exception) {
104                $this->adminLog->log(
105                    $this->currentUser,
106                    AdminLogType::AUTH_LOGIN_FAILED->value . ':' . $username . ' - '
107                        . implode(separator: ', ', array: $this->currentUser?->errors),
108                );
109                $this->session->getFlashBag()->add('error', Translation::get('ad_auth_fail'));
110                return new RedirectResponse(url: './login');
111            }
112        }
113
114        $this->session->getFlashBag()->add('error', Translation::get('ad_auth_fail'));
115        return new RedirectResponse(url: './login');
116    }
117
118    /**
119     * @throws UserException
120     * @throws Exception
121     * @throws \Exception
122     */
123    #[Route(path: '/login', name: 'admin.auth.login', methods: ['GET'])]
124    public function login(Request $request): Response
125    {
126        // Redirect to authenticate if SSO is enabled and the user is already authenticated
127        if (
128            (bool) $this->configuration->get(item: 'security.ssoSupport')
129            && $request->server->get(key: 'REMOTE_USER') !== null
130        ) {
131            return new RedirectResponse(url: './authenticate');
132        }
133        $errorMessages = $this->session->getFlashBag()->get('error');
134        $errorMessage = count($errorMessages) > 0 ? $errorMessages[0] : null;
135
136        return $this->render(file: '@admin/login.twig', context: [
137            ...$this->getHeader($request),
138            ...$this->getFooter(),
139            'isSecure' => $request->isSecure() || !$this->configuration->get(item: 'security.useSslForLogins'),
140            'isError' => $errorMessage !== null,
141            'errorMessage' => $errorMessage,
142            'loginMessage' => Translation::get(key: 'ad_auth_insert'),
143            'isLogout' => $request->query->get(key: 'action') === 'logout',
144            'logoutMessage' => Translation::get(key: 'ad_logout'),
145            'loginUrl' => $this->configuration->getDefaultUrl() . 'admin/authenticate',
146            'msgUsername' => Translation::get(key: 'ad_auth_user'),
147            'msgPassword' => Translation::get(key: 'ad_auth_passwd'),
148            'msgRememberMe' => Translation::get(key: 'rememberMe'),
149            'msgLostPassword' => Translation::get(key: 'lostPassword'),
150            'msgLoginUser' => Translation::get(key: 'msgLoginUser'),
151            'hasRegistrationEnabled' => $this->configuration->get(item: 'security.enableRegistration'),
152            'msgRegistration' => Translation::get(key: 'msgRegistration'),
153            'hasSignInWithMicrosoftActive' => $this->configuration->isSignInWithMicrosoftActive(),
154            'hasSignInWithKeycloakActive' => $this->configuration->isSignInWithKeycloakActive(),
155            'msgSignInWithMicrosoft' => Translation::get(key: 'msgSignInWithMicrosoft'),
156            'msgSignInWithKeycloak' => Translation::get(key: 'msgSignInWithKeycloak'),
157            'secureUrl' => preg_replace(pattern: '/^http:/', replacement: 'https:', subject: $request->getUri()),
158            'msgNotSecure' => Translation::get(key: 'msgSecureSwitch'),
159            'isWebAuthnEnabled' => $this->configuration->get(item: 'security.enableWebAuthnSupport'),
160        ]);
161    }
162
163    /**
164     * @throws \Exception
165     */
166    #[Route(path: '/logout', name: 'admin.auth.logout', methods: ['GET'])]
167    public function logout(Request $request): RedirectResponse
168    {
169        $this->userIsAuthenticated();
170
171        $redirectResponse = new RedirectResponse(url: $this->configuration->getDefaultUrl() . 'admin/login');
172
173        $csrfToken = Filter::filterVar($request->query->get(key: 'csrf'), FILTER_SANITIZE_SPECIAL_CHARS);
174
175        if (!Token::getInstance($this->session)->verifyToken(page: 'admin-logout', requestToken: $csrfToken)) {
176            // @todo add an error message
177            return $redirectResponse->send();
178        }
179
180        $this->adminLog->log(
181            $this->currentUser,
182            AdminLogType::AUTH_LOGOUT->value . ':' . $this->currentUser->getLogin(),
183        );
184
185        $this->currentUser->deleteFromSession(deleteCookie: true);
186        $ssoLogout = (string) ($this->configuration->get(item: 'security.ssoLogoutRedirect') ?? '');
187        if ((bool) $this->configuration->get(item: 'security.ssoSupport') && $ssoLogout !== '') {
188            $redirectResponse->isRedirect($ssoLogout);
189            $redirectResponse->send();
190        }
191
192        if (
193            $this->configuration->isSignInWithKeycloakActive()
194            && $this->currentUser->getUserAuthSource() === 'keycloak'
195        ) {
196            return new RedirectResponse($this->configuration->getDefaultUrl() . 'auth/keycloak/logout');
197        }
198
199        return $redirectResponse->send();
200    }
201
202    /**
203     * @throws \Exception
204     */
205    #[Route(path: '/token', name: 'admin.auth.token', methods: ['GET'])]
206    public function token(Request $request): Response
207    {
208        if ($this->currentUser->isLoggedIn()) {
209            return new RedirectResponse(url: './');
210        }
211
212        $userId = (int) Filter::filterVar($request->query->get(key: 'user-id'), FILTER_VALIDATE_INT);
213
214        return $this->render(file: '@admin/user/twofactor.twig', context: [
215            ...$this->getHeader($request),
216            ...$this->getFooter(),
217            'msgTwofactorEnabled' => Translation::get(key: 'msgTwofactorEnabled'),
218            'msgTwofactorCheck' => Translation::get(key: 'msgTwofactorCheck'),
219            'msgEnterTwofactorToken' => Translation::get(key: 'msgEnterTwofactorToken'),
220            'requestIsSecure' => $request->isSecure(),
221            'security.useSslForLogins' => $this->configuration->get(item: 'security.useSslForLogins'),
222            'requestHost' => $request->getHost(),
223            'requestUri' => $request->getRequestUri(),
224            'userId' => $userId,
225            'msgSecureSwitch' => Translation::get(key: 'msgSecureSwitch'),
226            'systemUri' => $this->configuration->getDefaultUrl(),
227        ]);
228    }
229
230    /**
231     * @throws \Exception
232     */
233    #[Route(path: '/check', name: 'admin.auth.check', methods: ['POST'])]
234    public function check(Request $request): RedirectResponse
235    {
236        if ($this->currentUser->isLoggedIn()) {
237            return new RedirectResponse(url: './');
238        }
239
240        $token = Filter::filterVar($request->request->get(key: 'token'), FILTER_SANITIZE_SPECIAL_CHARS, '');
241        $userId = (int) Filter::filterVar($request->request->get(key: 'user-id'), FILTER_VALIDATE_INT);
242
243        $session = $this->session;
244        $pendingUserId = $session->get('2fa_pending_user_id');
245
246        if ($pendingUserId === null || (int) $pendingUserId !== $userId) {
247            return new RedirectResponse(url: './login');
248        }
249
250        $user = $this->currentUserService;
251        $user->getUserById($userId);
252
253        // The failure count lives on the account, not in the session, so that neither
254        // a fresh session nor another password authentication can clear it.
255        if ($user->isTwoFactorLockedOut()) {
256            $session->remove('2fa_pending_user_id');
257            $session->remove('2fa_pending_remember_me');
258            return new RedirectResponse(url: './login');
259        }
260
261        if (strlen((string) $token) === 6) {
262            $tfa = $this->twoFactor;
263            $result = $tfa->validateToken($token, $userId);
264
265            if ($result) {
266                $session->remove('2fa_pending_user_id');
267                $rememberMe = true === $session->get('2fa_pending_remember_me');
268                $session->remove('2fa_pending_remember_me');
269                // twoFactorSuccess() clears the counter via setSuccess().
270                $user->twoFactorSuccess();
271                // The second factor is now verified, so the remember-me cookie can safely
272                // be issued for the fully authenticated session.
273                if ($rememberMe) {
274                    $user->issueRememberMeCookie();
275                }
276
277                $this->adminLog->log($user, AdminLogType::AUTH_2FA_SUCCESS->value . ':' . $user->getLogin());
278                return new RedirectResponse(url: './');
279            }
280
281            $this->adminLog->log($user, AdminLogType::AUTH_2FA_FAILED->value . ':' . $user->getLogin());
282        }
283
284        $user->twoFactorFailure();
285
286        return new RedirectResponse('./token?user-id=' . $userId);
287    }
288}