Lines 94.67% 551 / 582
Methods 67.64% 23 / 34
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 authenticate 64.44% 29 / 45 0.00% 0 / 1 14.49
 login 100.00% 30 / 30 100.00% 1 / 1 5
 logout 94.44% 17 / 18 0.00% 0 / 1 6.01
 token 100.00% 17 / 17 100.00% 1 / 1 2
 check 96.55% 28 / 29 0.00% 0 / 1 8
 [phpMyFAQ\Controller\Administration\AbstractAdministrationController] initializeFromContainer 80.00% 4 / 5 0.00% 0 / 1 2.03
 [phpMyFAQ\Controller\Administration\AbstractAdministrationController] render 100.00% 4 / 4 100.00% 1 / 1 1
 [phpMyFAQ\Controller\Administration\AbstractAdministrationController] getHeader 97.91% 47 / 48 0.00% 0 / 1 3
 [phpMyFAQ\Controller\Administration\AbstractAdministrationController] getSecondLevelEntries 100.00% 156 / 156 100.00% 1 / 1 6
 [phpMyFAQ\Controller\Administration\AbstractAdministrationController] getPageFlags 100.00% 87 / 87 100.00% 1 / 1 61
 [phpMyFAQ\Controller\Administration\AbstractAdministrationController] getGravatarImage 100.00% 8 / 8 100.00% 1 / 1 4
 [phpMyFAQ\Controller\Administration\AbstractAdministrationController] userHasPermission 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Controller\Administration\AbstractAdministrationController] getFooter 100.00% 12 / 12 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] setContainer 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] renderView 0.00% 0 / 3 0.00% 0 / 1 2
 [phpMyFAQ\Controller\AbstractController] json 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] getJsonObject 75.00% 3 / 4 0.00% 0 / 1 2.06
 [phpMyFAQ\Controller\AbstractController] getTwigWrapper 100.00% 10 / 10 100.00% 1 / 1 3
 [phpMyFAQ\Controller\AbstractController] hasValidToken 85.71% 6 / 7 0.00% 0 / 1 5.07
 [phpMyFAQ\Controller\AbstractController] isSecured 100.00% 10 / 10 100.00% 1 / 1 5
 [phpMyFAQ\Controller\AbstractController] isPublicAuthenticationPath 100.00% 23 / 23 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] userIsAuthenticated 100.00% 2 / 2 100.00% 1 / 1 2
 [phpMyFAQ\Controller\AbstractController] userIsSuperAdmin 100.00% 2 / 2 100.00% 1 / 1 2
 [phpMyFAQ\Controller\AbstractController] userHasGroupPermission 100.00% 8 / 8 100.00% 1 / 1 6
 [phpMyFAQ\Controller\AbstractController] userHasUserPermission 100.00% 7 / 7 100.00% 1 / 1 5
 [phpMyFAQ\Controller\AbstractController] userHasAnyPermission 100.00% 10 / 10 100.00% 1 / 1 4
 [phpMyFAQ\Controller\AbstractController] verifySessionCsrfToken 70.00% 7 / 10 0.00% 0 / 1 4.43
 [phpMyFAQ\Controller\AbstractController] captchaCodeIsValid 85.71% 6 / 7 0.00% 0 / 1 2.01
 [phpMyFAQ\Controller\AbstractController] isApiEnabled 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] addExtension 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] addFilter 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] getRateLimiter 100.00% 4 / 4 100.00% 1 / 1 3
 [phpMyFAQ\Controller\AbstractController] createFallbackContainer 71.42% 5 / 7 0.00% 0 / 1 2.09
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}

Inherited from phpMyFAQ\Controller\Administration\AbstractAdministrationController

42    protected function initializeFromContainer(): void
43    {
44        parent::initializeFromContainer();
45
46        $adminLog = $this->container->get(id: 'phpmyfaq.admin.admin-log');
47        if (!$adminLog instanceof AdminLog) {
48            throw new \LogicException('AdminLog service not found in container.');
49        }
50
51        $this->adminLog = $adminLog;
52    }
64    public function render(string $file, array $context = [], ?Response $response = null): Response
65    {
66        $response = parent::render($file, $context, $response);
67        $response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
68        $response->headers->set('Pragma', 'no-cache');
69
70        return $response;
71    }
77    protected function getHeader(Request $request): array
78    {
79        $adminHelper = $this->container->get(id: 'phpmyfaq.admin.helper');
80        if (!$adminHelper instanceof AdminMenuBuilder) {
81            throw new \LogicException('AdminMenuBuilder service not found in container.');
82        }
83
84        $adminHelper->setUser($this->currentUser);
85
86        $secLevelEntries = $this->getSecondLevelEntries($adminHelper);
87        $pageFlags = $this->getPageFlags($request);
88        $gravatarImage = $this->getGravatarImage();
89
90        return [
91            'metaLanguage' => Translation::get(key: 'metaLanguage'),
92            'layoutMode' => 'light',
93            'defaultLayoutMode' => (string) ($this->configuration->get('layout.defaultLayoutMode') ?? 'auto'),
94            'allowUserLayoutMode' =>
95                $this->configuration->get('layout.allowUserLayoutMode') === true
96                    || $this->configuration->get('layout.allowUserLayoutMode') === 'true',
97            'pageTitle' => $this->configuration->getTitle() . ' - ' . System::getPoweredByPlainString(),
98            'baseHref' => $this->configuration->getDefaultUrl() . 'admin/',
99            'version' => System::getVersion(),
100            'currentYear' => date(format: 'Y'),
101            'metaRobots' => $this->configuration->get(item: 'seo.metaTagsAdmin'),
102            'templateSetName' => TwigWrapper::getTemplateSetName(),
103            'pageDirection' => Translation::get(key: 'direction'),
104            'userHasAccessPermission' => $adminHelper->canAccessContent($this->currentUser),
105            'msgSessionExpiration' => Translation::get(key: 'ad_session_expiration'),
106            'renderedLanguageSelection' => LanguageHelper::renderSelectLanguage(
107                $this->configuration->getLanguage()->getLanguage(),
108                true,
109                [],
110                'lang',
111            ),
112            'userName' => $this->currentUser->getUserData('display_name'),
113            'hasGravatarSupport' => $this->configuration->get(item: 'main.enableGravatarSupport'),
114            'gravatarImage' => $gravatarImage,
115            'msgChangePassword' => Translation::get(key: 'ad_menu_passwd'),
116            'csrfTokenLogout' => Token::getInstance($this->session)->getTokenString('admin-logout'),
117            'msgLogout' => Translation::get(key: 'admin_mainmenu_logout'),
118            'secondLevelEntries' => $secLevelEntries,
119            'menuUsers' => Translation::get(key: 'admin_mainmenu_users'),
120            'menuContent' => Translation::get(key: 'admin_mainmenu_content'),
121            'menuStatistics' => Translation::get(key: 'admin_mainmenu_statistics'),
122            'menuImportsExports' => Translation::get(key: 'admin_mainmenu_imports_exports'),
123            'menuBackup' => Translation::get(key: 'admin_mainmenu_backup'),
124            'menuConfiguration' => Translation::get(key: 'admin_mainmenu_configuration'),
125            'isSessionTimeoutCounterEnabled' => $this->configuration->get(
126                item: 'security.enableAdminSessionTimeoutCounter',
127            ),
128            'pluginStylesheets' => $this->configuration->getPluginManager()->getAllPluginStylesheets(),
129            'pluginScripts' => $this->configuration->getPluginManager()->getAllPluginScripts(),
130        ] + $pageFlags;
131    }
136    private function getSecondLevelEntries(AdminMenuBuilder $adminHelper): array
137    {
138        $secLevelEntries = [];
139
140        $secLevelEntries['user'] = $adminHelper->addMenuEntry(
141            'add_user+edit_user+delete_user',
142            'ad_menu_user_administration',
143            'user',
144        );
145        if ($this->configuration->get(item: 'security.permLevel') !== 'basic') {
146            $secLevelEntries['user'] .= $adminHelper->addMenuEntry(
147                'addgroup+editgroup+delgroup',
148                'ad_menu_group_administration',
149                'group',
150            );
151        }
152
153        $secLevelEntries['user'] .= $adminHelper->addMenuEntry(
154            PermissionType::PASSWORD_CHANGE->value,
155            'ad_menu_passwd',
156            'password/change',
157        );
158
159        $secLevelEntries['content'] = $adminHelper->addMenuEntry(
160            'addcateg+editcateg+delcateg',
161            'msgHeaderCategoryOverview',
162            'category',
163        );
164        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
165            PermissionType::FAQ_ADD->value,
166            'msgAddFAQ',
167            'faq/add',
168        );
169        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
170            'edit_faq+delete_faq',
171            'msgHeaderFAQOverview',
172            'faqs',
173        );
174        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
175            PermissionType::FAQ_EDIT->value,
176            'stickyRecordsHeader',
177            'sticky-faqs',
178        );
179        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
180            PermissionType::FAQ_EDIT->value,
181            'msgOrphanedFAQs',
182            'orphaned-faqs',
183        );
184        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
185            PermissionType::QUESTION_DELETE->value,
186            'ad_menu_open',
187            'questions',
188        );
189        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
190            PermissionType::COMMENT_DELETE->value,
191            'ad_menu_comments',
192            'comments',
193        );
194        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
195            'addattachment+editattachment+delattachment',
196            'msgAttachments',
197            'attachments',
198        );
199        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(PermissionType::FAQ_EDIT->value, 'msgTags', 'tags');
200        $secLevelEntries['content'] .= $adminHelper->addMenuEntry(
201            'addglossary+editglossary+delglossary',
202            'ad_menu_glossary',
203            'glossary',
204        );
205        $secLevelEntries['content'] .= $adminHelper->addMenuEntry('addnews+editnews+delnews', 'msgNews', 'news');
206        $secLevelEntries['content'] .= $adminHelper->addMenuEntry('addpage+editpage+delpage', 'ad_menu_pages', 'pages');
207
208        $secLevelEntries['statistics'] = $adminHelper->addMenuEntry(
209            PermissionType::STATISTICS_VIEWLOGS->value,
210            'ad_menu_stat',
211            'statistics/ratings',
212        );
213        $secLevelEntries['statistics'] .= $adminHelper->addMenuEntry(
214            PermissionType::STATISTICS_VIEWLOGS->value,
215            'ad_menu_session',
216            'statistics/sessions',
217        );
218        $secLevelEntries['statistics'] .= $adminHelper->addMenuEntry(
219            PermissionType::STATISTICS_ADMINLOG->value,
220            'ad_menu_adminlog',
221            'statistics/admin-log',
222        );
223        $secLevelEntries['statistics'] .= $adminHelper->addMenuEntry(
224            PermissionType::STATISTICS_VIEWLOGS->value,
225            'msgAdminElasticsearchStats',
226            'statistics/search',
227        );
228        $secLevelEntries['statistics'] .= $adminHelper->addMenuEntry(
229            PermissionType::REPORTS->value,
230            'ad_menu_reports',
231            'statistics/report',
232        );
233
234        $secLevelEntries['imports_exports'] = $adminHelper->addMenuEntry(
235            PermissionType::FAQ_ADD->value,
236            'msgImportRecords',
237            'import',
238        );
239        $secLevelEntries['imports_exports'] .= $adminHelper->addMenuEntry(
240            PermissionType::EXPORT->value,
241            'ad_menu_export',
242            'export',
243        );
244
245        $secLevelEntries['backup'] = $adminHelper->addMenuEntry(
246            PermissionType::CONFIGURATION_EDIT->value,
247            'ad_menu_backup',
248            'backup',
249        );
250
251        $secLevelEntries['config'] = $adminHelper->addMenuEntry(
252            PermissionType::CONFIGURATION_EDIT->value,
253            'ad_menu_editconfig',
254            'configuration',
255        );
256        $secLevelEntries['config'] .= $adminHelper->addMenuEntry('forms_edit', 'msgEditForms', 'forms');
257        $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
258            'editinstances+addinstances+delinstances',
259            'ad_menu_instances',
260            'instances',
261        );
262        $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
263            PermissionType::CONFIGURATION_EDIT->value,
264            'ad_menu_stopwordsconfig',
265            'stopwords',
266        );
267        if ($this->configuration->get(item: 'upgrade.onlineUpdateEnabled')) {
268            $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
269                PermissionType::CONFIGURATION_EDIT->value,
270                'msgAdminHeaderUpdate',
271                'update',
272            );
273        }
274
275        $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
276            PermissionType::CONFIGURATION_EDIT->value,
277            'msgPlugins',
278            'plugins',
279        );
280        if ($this->configuration->get(item: 'search.enableElasticsearch')) {
281            $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
282                PermissionType::CONFIGURATION_EDIT->value,
283                'msgAdminHeaderElasticsearch',
284                'elasticsearch',
285            );
286        }
287
288        if ($this->configuration->isLdapActive()) {
289            $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
290                PermissionType::CONFIGURATION_EDIT->value,
291                'msgAdminHeaderLdap',
292                'ldap',
293            );
294        }
295
296        if ($this->configuration->get(item: 'search.enableOpenSearch')) {
297            $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
298                PermissionType::CONFIGURATION_EDIT->value,
299                'msgAdminHeaderOpenSearch',
300                'opensearch',
301            );
302        }
303
304        $secLevelEntries['config'] .= $adminHelper->addMenuEntry(
305            PermissionType::CONFIGURATION_EDIT->value,
306            'ad_system_info',
307            'system',
308        );
309
310        return $secLevelEntries;
311    }
316    private function getPageFlags(Request $request): array
317    {
318        $userPage = false;
319        $contentPage = false;
320        $statisticsPage = false;
321        $exportsPage = false;
322        $backupPage = false;
323        $configurationPage = false;
324
325        switch ($request->attributes->get('_route')) {
326            case 'admin.group':
327            case 'admin.group.add':
328            case 'admin.group.create':
329            case 'admin.password.change':
330            case 'admin.password.update':
331            case 'admin.user':
332            case 'admin.user.list':
333            case 'admin.user.edit':
334                $userPage = true;
335                break;
336            case 'admin.attachments':
337            case 'admin.category':
338            case 'admin.category.add':
339            case 'admin.category.add.child':
340            case 'admin.category.create':
341            case 'admin.category.edit':
342            case 'admin.category.hierarchy':
343            case 'admin.category.translate':
344            case 'admin.category.update':
345            case 'admin.content.orphaned-faqs':
346            case 'admin.content.sticky-faqs':
347            case 'admin.comments':
348            case 'admin.faq.add':
349            case 'admin.faq.answer':
350            case 'admin.faq.copy':
351            case 'admin.faq.edit':
352            case 'admin.faq.translate':
353            case 'admin.faqs':
354            case 'admin.glossary':
355            case 'admin.news':
356            case 'admin.news.add':
357            case 'admin.news.edit':
358            case 'admin.pages':
359            case 'admin.page.add':
360            case 'admin.page.edit':
361            case 'admin.page.translate':
362            case 'admin.questions':
363            case 'admin.tags':
364                $contentPage = true;
365                break;
366            case 'admin.statistics.admin-log':
367            case 'admin.statistics.ratings':
368            case 'admin.statistics.report':
369            case 'admin.statistics.sessions':
370            case 'admin.statistics.session.day':
371            case 'admin.statistics.session.id':
372            case 'admin.statistics.search':
373                $statisticsPage = true;
374                break;
375            case 'admin.export':
376            case 'admin.import':
377                $exportsPage = true;
378                break;
379            case 'admin.backup':
380            case 'admin.backup.export':
381            case 'admin.backup.restore':
382                $backupPage = true;
383                break;
384            case 'admin.configuration':
385            case 'admin.elasticsearch':
386            case 'admin.ldap':
387            case 'admin.opensearch':
388            case 'admin.forms':
389            case 'admin.instance.edit':
390            case 'admin.instance.update':
391            case 'admin.instances':
392            case 'admin.stopwords':
393            case 'admin.system':
394            case 'admin.configuration.plugins':
395            case 'admin.update':
396                $configurationPage = true;
397                break;
398        }
399
400        return [
401            'userPage' => $userPage,
402            'contentPage' => $contentPage,
403            'statisticsPage' => $statisticsPage,
404            'exportsPage' => $exportsPage,
405            'backupPage' => $backupPage,
406            'configurationPage' => $configurationPage,
407        ];
408    }
410    private function getGravatarImage(): string
411    {
412        if ($this->currentUser->isLoggedIn() && (bool) $this->configuration->get(item: 'main.enableGravatarSupport')) {
413            $email = $this->currentUser->getUserData('email');
414            $gravatar = new Gravatar();
415            return $gravatar->getImage(is_string($email) ? $email : '', [
416                'size' => '24',
417                'class' => 'img-profile rounded-circle',
418            ]);
419        }
420
421        return '';
422    }
428    protected function userHasPermission(PermissionType $permissionType): void
429    {
430        // Administration pages require authentication first: a logged-out user
431        // must be redirected to the login page (UnauthorizedHttpException),
432        // whereas ForbiddenException (403) is reserved for authenticated users
433        // who lack the required permission.
434        $this->userIsAuthenticated();
435        parent::userHasPermission($permissionType);
436    }
441    protected function getFooter(): array
442    {
443        return [
444            'msgModalSessionWarning' => sprintf(
445                Translation::getString('ad_session_expiring'),
446                PMF_AUTH_TIMEOUT_WARNING,
447            ),
448            'msgPoweredBy' => System::getPoweredByPlainString(),
449            'documentationUrl' => System::getDocumentationUrl(),
450            'phpMyFaqUrl' => System::PHPMYFAQ_URL,
451            'isUserLoggedIn' => $this->currentUser->isLoggedIn(),
452            'currentLanguage' => $this->configuration->getLanguage()->getLanguage(),
453            'currentYear' => date(format: 'Y'),
454        ];
455    }

Inherited from phpMyFAQ\Controller\AbstractController

93    public function setContainer(ContainerInterface $container): void
94    {
95        $this->container = $container;
96        $this->initializeFromContainer();
97    }
154    public function renderView(string $pathToTwigFile, array $templateVars = []): string
155    {
156        $twigWrapper = $this->getTwigWrapper();
157        $templateWrapper = $twigWrapper->loadTemplate($pathToTwigFile);
158
159        return $templateWrapper->render($templateVars);
160    }
167    public function json(mixed $data, int $status = 200, array $headers = []): JsonResponse
168    {
169        return new JsonResponse($data, $status, $headers);
170    }
182    protected function getJsonObject(Request $request): \stdClass
183    {
184        /* @mago-expect analysis:mixed-assignment - json_decode() is mixed by nature; validated to stdClass below */
185        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
186
187        if (!$data instanceof \stdClass) {
188            throw new JsonException('The request body must be a JSON object.');
189        }
190
191        return $data;
192    }
197    public function getTwigWrapper(): TwigWrapper
198    {
199        $twigWrapper = new TwigWrapper(
200            (string) PMF_ROOT_DIR . '/assets/templates',
201            false,
202            $this->configuration->getTemplateSet(),
203        );
204
205        foreach ($this->twigExtensions as $twigExtension) {
206            $twigWrapper->addExtension($twigExtension);
207        }
208
209        foreach ($this->twigFilters as $twigFilter) {
210            $twigWrapper->addFilter($twigFilter);
211        }
212
213        return $twigWrapper;
214    }
219    protected function hasValidToken(): void
220    {
221        $configuredToken = $this->configuration->get(item: 'api.apiClientToken');
222        if (!is_string($configuredToken) || $configuredToken === '') {
223            throw new UnauthorizedHttpException(challenge: '"x-pmf-token" is not valid.');
224        }
225
226        $request = Request::createFromGlobals();
227        $requestToken = $request->headers->get(key: 'x-pmf-token');
228        if (!is_string($requestToken) || !hash_equals($configuredToken, $requestToken)) {
229            throw new UnauthorizedHttpException(challenge: '"x-pmf-token" is not valid.');
230        }
231    }
236    protected function isSecured(): void
237    {
238        if ($this->currentUser->isLoggedIn()) {
239            return;
240        }
241
242        if (!$this->configuration->get(item: 'security.enableLoginOnly')) {
243            return;
244        }
245
246        $request = Request::createFromGlobals();
247        $pathInfo = rtrim($request->getPathInfo(), characters: '/');
248        $pathInfo = $pathInfo === '' ? '/' : $pathInfo;
249
250        if ($this->isPublicAuthenticationPath($pathInfo)) {
251            return;
252        }
253
254        throw new UnauthorizedHttpException(challenge: 'You are not allowed to view this content.');
255    }
257    private function isPublicAuthenticationPath(string $pathInfo): bool
258    {
259        $publicAuthenticationPaths = [
260            '/login',
261            '/authenticate',
262            '/forgot-password',
263            '/token',
264            '/check',
265            '/contact.html',
266            '/imprint.html',
267            '/privacy.html',
268            '/terms.html',
269            '/accessibility.html',
270            '/auth/azure/authorize',
271            '/auth/azure/callback',
272            '/auth/azure/callback.php',
273            '/auth/keycloak/authorize',
274            '/auth/keycloak/callback',
275            '/auth/keycloak/logout',
276            '/services/azure/callback',
277            '/services/azure/callback.php',
278            '/api/webauthn/prepare-login',
279            '/api/webauthn/login',
280        ];
281
282        return in_array($pathInfo, $publicAuthenticationPaths, strict: true);
283    }
288    public function userIsAuthenticated(): void
289    {
290        if (!$this->currentUser->isLoggedIn()) {
291            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
292        }
293    }
298    protected function userIsSuperAdmin(): void
299    {
300        if (!$this->currentUser->isSuperAdmin()) {
301            throw new UnauthorizedHttpException(challenge: 'User is not super admin.');
302        }
303    }
308    protected function userHasGroupPermission(): void
309    {
310        if (!$this->currentUser->isLoggedIn()) {
311            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
312        }
313
314        $currentUser = $this->currentUser;
315        if (
316            !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_ADD->value)
317            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_EDIT->value)
318            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_DELETE->value)
319            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::GROUP_EDIT->value)
320        ) {
321            throw new ForbiddenException(message: 'User has no group permission.');
322        }
323    }
328    protected function userHasUserPermission(): void
329    {
330        if (!$this->currentUser->isLoggedIn()) {
331            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
332        }
333
334        $currentUser = $this->currentUser;
335        if (
336            !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_ADD->value)
337            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_EDIT->value)
338            || !$currentUser->perm->hasPermission($currentUser->getUserId(), PermissionType::USER_DELETE->value)
339        ) {
340            throw new ForbiddenException(message: 'User has no user permission.');
341        }
342    }
364    protected function userHasAnyPermission(PermissionType ...$permissionTypes): void
365    {
366        if (!$this->currentUser->isLoggedIn()) {
367            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
368        }
369
370        $currentUser = $this->currentUser;
371        foreach ($permissionTypes as $permissionType) {
372            if ($currentUser->perm->hasPermission($currentUser->getUserId(), $permissionType->value)) {
373                return;
374            }
375        }
376
377        throw new ForbiddenException(message: sprintf('User has none of the required permissions: %s.', implode(', ', array_map(
378            static fn(PermissionType $type): string => $type->name,
379            $permissionTypes,
380        ))));
381    }
389    protected function verifySessionCsrfToken(string $page, #[\SensitiveParameter] string $requestToken): bool
390    {
391        if ($requestToken === '') {
392            return false;
393        }
394
395        $sessionKey = sprintf('pmf-csrf-token.%s', $page);
396        $storedToken = $this->session->get($sessionKey);
397
398        if (!$storedToken instanceof Token) {
399            return false;
400        }
401
402        if (time() > $storedToken->getExpiry()) {
403            $this->session->remove($sessionKey);
404            return false;
405        }
406
407        return hash_equals($storedToken->getSessionToken(), $requestToken);
408    }
414    protected function captchaCodeIsValid(Request $request): bool
415    {
416        $captcha = Captcha::getInstance($this->configuration);
417        $captcha->setUserIsLoggedIn($this->currentUser->isLoggedIn());
418
419        $data = json_decode($request->getContent(), associative: false, depth: 512, flags: JSON_THROW_ON_ERROR);
420
421        $code = Filter::filterVar($data->captcha ?? '', FILTER_SANITIZE_SPECIAL_CHARS);
422        if ($this->configuration->get(item: 'security.enableGoogleReCaptchaV2')) {
423            $code = Filter::filterVar($data->{'g-recaptcha-response'} ?? '', FILTER_SANITIZE_SPECIAL_CHARS);
424        }
425
426        return $captcha->checkCaptchaCode((string) $code);
427    }
429    public function isApiEnabled(): bool
430    {
431        return (bool) $this->configuration->get(item: 'api.enableAccess');
432    }
434    public function addExtension(ExtensionInterface $extension): void
435    {
436        $this->twigExtensions[] = $extension;
437    }
439    public function addFilter(TwigFilter $twigFilter): void
440    {
441        $this->twigFilters[] = $twigFilter;
442    }
444    protected function getRateLimiter(): ?RateLimiter
445    {
446        if (!$this->container->has('phpmyfaq.http.rate-limiter')) {
447            return null;
448        }
449
450        $rateLimiter = $this->container->get('phpmyfaq.http.rate-limiter');
451
452        return $rateLimiter instanceof RateLimiter ? $rateLimiter : null;
453    }
455    private function createFallbackContainer(): ContainerBuilder
456    {
457        $containerBuilder = new ContainerBuilder();
458        $phpFileLoader = new PhpFileLoader($containerBuilder, new FileLocator(__DIR__));
459        try {
460            $phpFileLoader->load(resource: '../../services.php');
461        } catch (\Exception $exception) {
462            error_log($exception->getMessage());
463        }
464
465        // Register Forms services
466        FormsServiceProvider::register($containerBuilder);
467
468        return $containerBuilder;
469    }