Lines 87.95% 387 / 440
Methods 61.76% 21 / 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
 list 100.00% 27 / 27 100.00% 1 / 1 5
 csvExport 100.00% 39 / 39 100.00% 1 / 1 5
 userData 100.00% 16 / 16 100.00% 1 / 1 2
 userPermissions 100.00% 5 / 5 100.00% 1 / 1 1
 activate 70.00% 14 / 20 0.00% 0 / 1 9.73
 overwritePassword 83.87% 26 / 31 0.00% 0 / 1 15.94
 deleteUser 61.53% 16 / 26 0.00% 0 / 1 9.79
 addUser 70.45% 31 / 44 0.00% 0 / 1 20.80
 editUser 91.22% 52 / 57 0.00% 0 / 1 21.30
 updateUserRights 94.87% 37 / 39 0.00% 0 / 1 14.03
 [phpMyFAQ\Controller\Administration\Api\AbstractAdministrationApiController] initializeFromContainer 80.00% 4 / 5 0.00% 0 / 1 2.03
 [phpMyFAQ\Controller\AbstractController] setContainer 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Controller\AbstractController] render 100.00% 5 / 5 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] userHasPermission 100.00% 5 / 5 100.00% 1 / 1 3
 [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
45final class UserController extends AbstractAdministrationApiController
46{
47    public function __construct(
48        private readonly CurrentUser $currentUserService,
49    ) {
50        parent::__construct();
51    }
52
53    /**
54     * @throws Exception
55     */
56    #[Route(path: 'user/users', name: 'admin.api.user.users', methods: ['GET'])]
57    public function list(Request $request): JsonResponse
58    {
59        $this->userHasUserPermission();
60
61        $currentUser = CurrentUser::getCurrentUser($this->configuration);
62
63        $filtered = Filter::filterVar($request->query->get(key: 'filter'), FILTER_SANITIZE_SPECIAL_CHARS, '');
64
65        if ('' === $filtered) {
66            $allUsers = $currentUser->getAllUsers(withoutAnonymous: false);
67            $userData = [];
68            foreach ($allUsers as $allUser) {
69                $currentUser->getUserById($allUser, allowBlockedUsers: true);
70                $user = new stdClass();
71                $user->id = $currentUser->getUserId();
72                $user->status = $currentUser->getStatus();
73                $user->isSuperAdmin = $currentUser->isSuperAdmin();
74                $user->isVisible = $currentUser->getUserData(field: 'is_visible');
75                $displayName = $currentUser->getUserData(field: 'display_name');
76                $user->displayName = Report::sanitize(is_string($displayName) ? $displayName : '');
77                $user->userName = Report::sanitize($currentUser->getLogin());
78                $user->email = $currentUser->getUserData(field: 'email');
79                $user->authSource = $currentUser->getUserAuthSource();
80                $userData[] = $user;
81            }
82
83            return $this->json($userData, Response::HTTP_OK);
84        }
85
86        $allUsers = [];
87        foreach ($currentUser->searchUsers($filtered) as $singleUser) {
88            $users = new stdClass();
89            $users->label = $singleUser['login'];
90            $users->value = (int) $singleUser['user_id'];
91            $allUsers[] = $users;
92        }
93
94        return $this->json($allUsers, Response::HTTP_OK);
95    }
96
97    /**
98     * @throws Exception
99     */
100    #[Route(path: 'user/users.csv', name: 'admin.api.user.users.csv', methods: ['GET'])]
101    public function csvExport(): Response
102    {
103        $this->userHasUserPermission();
104
105        $currentUser = CurrentUser::getCurrentUser($this->configuration);
106        $allUsers = $currentUser->getAllUsers(withoutAnonymous: false);
107
108        $handle = fopen(filename: 'php://temp', mode: 'r+');
109        fputcsv(
110            $handle,
111            ['ID', 'Status', 'Super Admin', 'Visible', 'Display Name', 'Username', 'Email', 'Auth Source'],
112            separator: ',',
113            enclosure: '"',
114            eol: PHP_EOL,
115        );
116
117        foreach ($allUsers as $allUser) {
118            $currentUser->getUserById($allUser, allowBlockedUsers: true);
119            fputcsv(
120                $handle,
121                [
122                    $currentUser->getUserId(),
123                    $currentUser->getStatus(),
124                    $currentUser->isSuperAdmin() ? 'true' : 'false',
125                    $currentUser->getUserData(field: 'is_visible') ? 'true' : 'false',
126                    Report::sanitize(
127                        is_string($displayName = $currentUser->getUserData(field: 'display_name')) ? $displayName : '',
128                    ),
129                    Report::sanitize($currentUser->getLogin()),
130                    $currentUser->getUserData(field: 'email'),
131                    $currentUser->getUserAuthSource(),
132                ],
133                separator: ',',
134                enclosure: '"',
135                eol: PHP_EOL,
136            );
137        }
138
139        rewind($handle);
140
141        $content = (string) stream_get_contents($handle);
142
143        fclose($handle);
144
145        $this->adminLog->log($this->currentUser, AdminLogType::DATA_EXPORT_USERS->value);
146
147        $response = new Response($content);
148        $response->headers->set(key: 'Content-Type', values: 'text/csv');
149        $response->headers->set(key: 'Content-Disposition', values: 'attachment; filename="users.csv"');
150
151        return $response;
152    }
153
154    /**
155     * @throws Exception|\Exception
156     */
157    #[Route(path: 'user/data/{userId}', name: 'admin.api.user.data', methods: ['GET'])]
158    public function userData(Request $request): JsonResponse
159    {
160        $this->userHasUserPermission();
161
162        $this->currentUserService->getUserById((int) $request->attributes->get(key: 'userId'), allowBlockedUsers: true);
163
164        $userData = [];
165
166        $data = $this->currentUserService->userData()->get(field: '*');
167        if (is_array($data)) {
168            $userData = $data;
169            $userData['userId'] = $this->currentUserService->getUserId();
170            $userData['status'] = $this->currentUserService->getStatus();
171            $userData['login'] = $this->currentUserService->getLogin();
172            $userData['displayName'] = $userData['display_name'];
173            $userData['isSuperadmin'] = $this->currentUserService->isSuperAdmin();
174            $userData['authSource'] = $this->currentUserService->getUserAuthSource();
175            $userData['isVisible'] = $userData['is_visible'];
176            $userData['twoFactorEnabled'] = $userData['twofactor_enabled'];
177            $userData['lastModified'] = $userData['last_modified'];
178        }
179
180        return $this->json($userData, Response::HTTP_OK);
181    }
182
183    /**
184     * @throws Exception
185     */
186    #[Route(path: 'user/permissions/{userId}', name: 'admin.api.user.permissions', methods: ['GET'])]
187    public function userPermissions(Request $request): JsonResponse
188    {
189        $this->userHasUserPermission();
190
191        $currentUser = CurrentUser::getCurrentUser($this->configuration);
192
193        $userId = $request->attributes->get(key: 'userId');
194        $currentUser->getUserById((int) $userId, allowBlockedUsers: true);
195
196        return $this->json($currentUser->perm->getUserRights((int) $userId), Response::HTTP_OK);
197    }
198
199    /**
200     * @throws Exception
201     * @throws \Exception
202     */
203    #[Route(path: 'user/activate', name: 'admin.api.user.activate', methods: ['PUT'])]
204    public function activate(Request $request): JsonResponse
205    {
206        $this->userHasUserPermission();
207
208        $currentUser = CurrentUser::getCurrentUser($this->configuration);
209
210        $data = $this->getJsonObject($request);
211        if (!Token::getInstance($this->session)->verifyToken(
212            page: 'activate-user',
213            requestToken: (string) ($data->csrfToken ?? ''),
214        )) {
215            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
216        }
217
218        $userId = (int) Filter::filterVar($data->userId ?? null, FILTER_VALIDATE_INT);
219
220        if (!$currentUser->getUserById($userId, allowBlockedUsers: true)) {
221            return $this->json(['error' => Translation::get(key: 'ad_user_error_noId')], Response::HTTP_BAD_REQUEST);
222        }
223
224        // A non-SuperAdmin must never be able to alter a SuperAdmin or protected account.
225        if (
226            !$this->currentUser->isSuperAdmin()
227            && ($currentUser->isSuperAdmin() || $currentUser->getStatus() === 'protected')
228        ) {
229            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
230        }
231
232        try {
233            if ($currentUser->activateUser()) {
234                $this->adminLog->log($this->currentUser, AdminLogType::USER_EDIT->value . ' (activated):' . $userId);
235                return $this->json(['success' => $currentUser->getStatus()], Response::HTTP_OK);
236            }
237
238            return $this->json(['error' => $currentUser->getStatus()], Response::HTTP_BAD_REQUEST);
239        } catch (TransportExceptionInterface|\Exception $exception) {
240            return $this->json(['error' => $exception->getMessage()], Response::HTTP_BAD_REQUEST);
241        }
242    }
243
244    /**
245     * @throws Exception
246     * @throws \Exception
247     */
248    #[Route(path: 'user/overwrite-password', name: 'admin.api.user.overwrite-password', methods: ['PUT'])]
249    public function overwritePassword(Request $request): JsonResponse
250    {
251        $this->userHasUserPermission();
252
253        $data = $this->getJsonObject($request);
254
255        $userId = (int) Filter::filterVar($data->userId ?? null, FILTER_VALIDATE_INT);
256        $csrfToken = Filter::filterVar($data->csrf ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
257        $newPassword = is_string($data->newPassword ?? null) ? $data->newPassword : '';
258        $retypedPassword = is_string($data->passwordRepeat ?? null) ? $data->passwordRepeat : '';
259
260        if (!Token::getInstance($this->session)->verifyToken(page: 'overwrite-password', requestToken: $csrfToken)) {
261            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
262        }
263
264        if ($userId <= 0) {
265            return $this->json(['error' => Translation::get(key: 'ad_user_error_noId')], Response::HTTP_BAD_REQUEST);
266        }
267
268        if (strlen($newPassword) <= 7 || strlen($retypedPassword) <= 7) {
269            return $this->json(['error' => Translation::get(key: 'msgPasswordTooShort')], Response::HTTP_BAD_REQUEST);
270        }
271
272        $isSelf = $this->currentUser->getUserId() === (int) $userId;
273        $actingIsSuperAdmin = $this->currentUser->isSuperAdmin();
274
275        // Only SuperAdmins may change other users' passwords. Self-service is always allowed.
276        if (!$isSelf && !$actingIsSuperAdmin) {
277            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
278        }
279
280        $targetUser = new User($this->configuration);
281        $targetUser->getUserById((int) $userId, allowBlockedUsers: true);
282
283        if ($targetUser->getUserId() <= 0) {
284            return $this->json(['error' => Translation::get(key: 'ad_user_error_noId')], Response::HTTP_BAD_REQUEST);
285        }
286
287        // Defense in depth: a non-SuperAdmin must never be able to alter a SuperAdmin or protected account,
288        // even when isSelf would short-circuit the check above.
289        if (!$actingIsSuperAdmin && ($targetUser->isSuperAdmin() || $targetUser->getStatus() === 'protected')) {
290            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
291        }
292
293        $auth = new Auth($this->configuration);
294        $authSource = $auth->selectAuth($targetUser->getAuthSource(key: 'name') ?? '');
295        $authSource->getEncryptionContainer((string) ($targetUser->getAuthData(key: 'encType') ?? ''));
296
297        if (hash_equals($newPassword, $retypedPassword)) {
298            if (!$targetUser->changePassword($newPassword)) {
299                return $this->json(['error' => Translation::get(key: 'ad_passwd_fail')], Response::HTTP_BAD_REQUEST);
300            }
301
302            $this->adminLog->log($this->currentUser, AdminLogType::USER_CHANGE_PASSWORD->value . ':' . $userId);
303
304            return $this->json(['success' => Translation::get(key: 'ad_passwdsuc')], Response::HTTP_OK);
305        }
306
307        return $this->json(['error' => Translation::get(key: 'msgPasswordsMustBeEqual')], Response::HTTP_BAD_REQUEST);
308    }
309
310    /**
311     * @throws Exception
312     * @throws \Exception
313     */
314    #[Route(path: 'user/delete', name: 'admin.api.user.delete', methods: ['DELETE'])]
315    public function deleteUser(Request $request): JsonResponse
316    {
317        $this->userHasPermission(PermissionType::USER_DELETE);
318
319        $currentUser = CurrentUser::getCurrentUser($this->configuration);
320
321        $data = $this->getJsonObject($request);
322
323        if (!Token::getInstance($this->session)->verifyToken(
324            page: 'delete-user',
325            requestToken: (string) ($data->csrfToken ?? ''),
326        )) {
327            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
328        }
329
330        $userId = Filter::filterVar($data->userId ?? null, FILTER_VALIDATE_INT);
331
332        if (!is_int($userId)) {
333            return $this->json(['error' => Translation::get(key: 'ad_user_error_noId')], Response::HTTP_BAD_REQUEST);
334        }
335
336        $currentUser->getUserById($userId, allowBlockedUsers: true);
337        $superAdminIds = User::getSuperAdminIds($this->configuration);
338        if ($currentUser->getStatus() === 'protected' || in_array($userId, $superAdminIds, strict: true)) {
339            return $this->json([
340                'error' => Translation::get(key: 'ad_user_error_protectedAccount'),
341            ], Response::HTTP_BAD_REQUEST);
342        }
343
344        if (!$currentUser->deleteUser()) {
345            return $this->json(['error' => Translation::get(key: 'ad_user_error_delete')], Response::HTTP_BAD_REQUEST);
346        }
347
348        $category = new Category($this->configuration, [], withPermission: false);
349        $category->moveOwnership((int) $userId, newOwner: 1);
350
351        // Remove the user from groups
352        if ('basic' !== $this->configuration->get(item: 'security.permLevel')) {
353            $permissions = new MediumPermission($this->configuration);
354            $permissions->removeFromAllGroups($userId);
355        }
356
357        $this->adminLog->log($this->currentUser, AdminLogType::USER_DELETE->value . ':' . $userId);
358
359        return $this->json(['success' => Translation::get(key: 'ad_user_deleted')], Response::HTTP_OK);
360    }
361
362    /**
363     * @throws Exception
364     * @throws \Exception
365     */
366    #[Route(path: 'user/add', name: 'admin.api.user.add', methods: ['POST'])]
367    public function addUser(Request $request): JsonResponse
368    {
369        $this->userHasUserPermission();
370
371        $data = $this->getJsonObject($request);
372
373        if (!Token::getInstance($this->session)->verifyToken(
374            page: 'add-user',
375            requestToken: (string) ($data->csrf ?? ''),
376        )) {
377            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
378        }
379
380        $errorMessage = [];
381
382        $userName = Filter::filterVar($data->userName ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
383        $userRealName = trim(strip_tags((string) ($data->realName ?? '')));
384        $userEmail = (string) Filter::filterEmail($data->email ?? '', default: '');
385        $automaticPassword = (bool) Filter::filterVar($data->automaticPassword ?? false, FILTER_VALIDATE_BOOLEAN);
386        $userPassword = Filter::filterVar($data->password ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
387        $userPasswordConfirm = Filter::filterVar($data->passwordConfirm ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
388        $userIsSuperAdmin = (bool) Filter::filterVar($data->isSuperAdmin ?? false, FILTER_VALIDATE_BOOLEAN);
389
390        // Only SuperAdmins may grant the SuperAdmin flag. Reject the request when a
391        // non-SuperAdmin attempts to set it, to prevent privilege escalation through
392        // mass-assignment of is_superadmin on user creation.
393        if (!$this->currentUser->isSuperAdmin() && $userIsSuperAdmin) {
394            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
395        }
396
397        $newUser = new User($this->configuration);
398
399        if (!$newUser->isValidLogin($userName)) {
400            $errorMessage[] = Translation::get(key: 'ad_user_error_loginInvalid');
401        }
402
403        if ($newUser->getUserByLogin($userName, raiseError: false)) {
404            $errorMessage[] = Translation::get(key: 'ad_adus_exerr');
405        }
406
407        if ($userRealName === '') {
408            $errorMessage[] = Translation::get(key: 'ad_user_error_noRealName');
409        }
410
411        if ($userEmail === '') {
412            $errorMessage[] = Translation::get(key: 'ad_user_error_noEmail');
413        }
414
415        if (!$automaticPassword && (strlen($userPassword) <= 7 || strlen($userPasswordConfirm) <= 7)) {
416            $errorMessage[] = Translation::get(key: 'ad_passwd_fail');
417        }
418
419        if ($automaticPassword) {
420            $userPassword = $newUser->createPassword(minimumLength: 8, allowUnderscore: false);
421        }
422
423        if ($errorMessage === []) {
424            if (!$newUser->createUser($userName, $userPassword)) {
425                $errorMessage[] = $newUser->error();
426                return $this->json($errorMessage, Response::HTTP_BAD_REQUEST);
427            }
428
429            $newUser->userData()->set(['display_name', 'email', 'is_visible'], [$userRealName, $userEmail, 0]);
430            $newUser->setStatus(status: 'active');
431            $newUser->setSuperAdmin($userIsSuperAdmin);
432
433            $mailHelper = new MailHelper($this->configuration);
434            try {
435                $mailHelper->sendMailToNewUser($newUser, $userPassword);
436            } catch (Exception|TransportExceptionInterface $exception) {
437                $this->configuration->getLogger()->warning('Failed to send new user mail.', [$exception->getMessage()]);
438            }
439
440            $this->adminLog->log($this->currentUser, AdminLogType::USER_ADD->value . ':' . $newUser->getUserId());
441
442            return $this->json(['success' => Translation::get(key: 'ad_adus_suc')], Response::HTTP_OK);
443        }
444
445        return $this->json($errorMessage, Response::HTTP_BAD_REQUEST);
446    }
447
448    /**
449     * @throws Exception|\Exception|TransportExceptionInterface
450     */
451    #[Route(path: 'user/edit', name: 'admin.api.user.edit', methods: ['PUT'])]
452    public function editUser(Request $request): JsonResponse
453    {
454        $this->userHasPermission(PermissionType::USER_EDIT);
455
456        $data = $this->getJsonObject($request);
457
458        if (!Token::getInstance($this->session)->verifyToken(
459            page: 'update-user-data',
460            requestToken: (string) ($data->csrfToken ?? ''),
461        )) {
462            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
463        }
464
465        $userId = (int) Filter::filterVar($data->userId ?? null, FILTER_VALIDATE_INT, default: 0);
466        if ($userId === 0) {
467            return $this->json(['error' => Translation::get(key: 'ad_user_error_noId')], Response::HTTP_BAD_REQUEST);
468        }
469
470        $userData = [];
471        $userData['display_name'] = trim(strip_tags((string) ($data->display_name ?? '')));
472        $userData['email'] = (string) Filter::filterEmail($data->email ?? '', default: '');
473        $userData['last_modified'] = Filter::filterVar($data->last_modified ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
474        $userStatus = Filter::filterVar(
475            $data->user_status ?? 'active',
476            FILTER_SANITIZE_SPECIAL_CHARS,
477            default: 'active',
478        );
479        $isSuperAdmin = Filter::filterVar($data->is_superadmin ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
480        $deleteTwoFactor = (bool) Filter::filterVar($data->overwrite_twofactor ?? false, FILTER_VALIDATE_BOOLEAN);
481
482        $actingIsSuperAdmin = $this->currentUser->isSuperAdmin();
483
484        // Only SuperAdmins may grant or revoke the SuperAdmin flag. Reject the request when a
485        // non-SuperAdmin attempts to set it, to prevent privilege escalation through
486        // mass-assignment of is_superadmin.
487        if (!$actingIsSuperAdmin && (bool) $isSuperAdmin) {
488            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
489        }
490
491        $user = new User($this->configuration);
492        if (!$user->getUserById($userId, allowBlockedUsers: true)) {
493            return $this->json(['error' => Translation::get(key: 'ad_user_error_noId')], Response::HTTP_BAD_REQUEST);
494        }
495
496        // Defense in depth: a non-SuperAdmin must never be able to alter a SuperAdmin or
497        // protected account.
498        if (!$actingIsSuperAdmin && ($user->isSuperAdmin() || $user->getStatus() === 'protected')) {
499            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
500        }
501
502        $stats = $user->getStatus();
503        $wasSuperAdmin = $user->isSuperAdmin();
504
505        // reset two-factor authentication if required
506        if ($deleteTwoFactor) {
507            $user->setUserData(['secret' => '', 'twofactor_enabled' => 0]);
508            $this->adminLog->log($this->currentUser, AdminLogType::AUTH_2FA_RESET->value . ':' . $userId);
509        }
510
511        // set a new password and sent email if a user is switched to active
512        if ($stats === 'blocked' && $userStatus === 'active' && !$user->activateUser()) {
513            $userStatus = 'invalid_status';
514        }
515
516        // Only SuperAdmins may change the super-admin flag.
517        if ($actingIsSuperAdmin) {
518            $user->setSuperAdmin((bool) $isSuperAdmin);
519        }
520
521        // Log status change
522        if ($stats !== $userStatus) {
523            $this->adminLog->log(
524                $this->currentUser,
525                AdminLogType::USER_STATUS_CHANGED->value . ':' . $userId . ' (' . $stats . ' -> ' . $userStatus . ')',
526            );
527        }
528
529        // Log super-admin flag changes
530        if (!$wasSuperAdmin && (bool) $isSuperAdmin) {
531            $this->adminLog->log($this->currentUser, AdminLogType::USER_SUPERADMIN_GRANTED->value . ':' . $userId);
532        }
533
534        if ($wasSuperAdmin && !(bool) $isSuperAdmin) {
535            $this->adminLog->log($this->currentUser, AdminLogType::USER_SUPERADMIN_REVOKED->value . ':' . $userId);
536        }
537
538        if (!$user->userData()->set(array_keys($userData), array_values($userData)) || !$user->setStatus($userStatus)) {
539            return $this->json(['error' => 'ad_msg_mysqlerr'], Response::HTTP_BAD_REQUEST);
540        }
541
542        $this->adminLog->log($this->currentUser, AdminLogType::USER_EDIT->value . ':' . $userId);
543
544        $success =
545            Translation::getString(key: 'ad_msg_savedsuc_1')
546            . ' "'
547            . Strings::htmlentities($user->getLogin(), ENT_QUOTES)
548            . '" '
549            . Translation::getString(key: 'ad_msg_savedsuc_2');
550        return $this->json(['success' => $success], Response::HTTP_OK);
551    }
552
553    /**
554     * @throws Exception
555     * @throws \Exception
556     */
557    #[Route(path: 'user/update-rights', name: 'admin.api.user.update-rights', methods: ['PUT'])]
558    public function updateUserRights(Request $request): JsonResponse
559    {
560        $this->userHasPermission(PermissionType::USER_EDIT);
561
562        $data = $this->getJsonObject($request);
563
564        if (!Token::getInstance($this->session)->verifyToken(
565            page: 'update-user-rights',
566            requestToken: (string) ($data->csrfToken ?? ''),
567        )) {
568            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
569        }
570
571        $userId = (int) Filter::filterVar($data->userId ?? null, FILTER_VALIDATE_INT, default: 0);
572
573        if (0 === (int) $userId) {
574            return $this->json(['error' => Translation::get(key: 'ad_user_error_noId')], Response::HTTP_BAD_REQUEST);
575        }
576
577        // userRights arrives as a JSON array of permission ids. Validate each element as a
578        // positive integer and drop anything malformed, so only real right ids can reach the
579        // escalation guard and grantUserRight() (a bad value must never become right id 0).
580        $submittedRights = is_array($data->userRights ?? null) ? $data->userRights : [];
581        $userRights = [];
582        foreach ($submittedRights as $submittedRight) {
583            $rightId = Filter::filterVar($submittedRight, FILTER_VALIDATE_INT, default: 0);
584            if ($rightId > 0) {
585                $userRights[] = $rightId;
586            }
587        }
588
589        $actingIsSuperAdmin = $this->currentUser->isSuperAdmin();
590
591        // A non-SuperAdmin may only assign rights they hold themselves. This prevents an
592        // administrator with the delegable USER_EDIT right from granting privileges they do not
593        // possess (privilege escalation).
594        if (!$actingIsSuperAdmin) {
595            $actingUserId = $this->currentUser->getUserId();
596            foreach ($userRights as $userRight) {
597                if (!$this->currentUser->perm->hasPermission($actingUserId, (int) $userRight)) {
598                    return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
599                }
600            }
601        }
602
603        $user = new User($this->configuration);
604        $user->getUserById($userId);
605
606        // Defense in depth: a non-SuperAdmin must never be able to alter a SuperAdmin or
607        // protected account.
608        if (!$actingIsSuperAdmin && ($user->isSuperAdmin() || $user->getStatus() === 'protected')) {
609            return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_FORBIDDEN);
610        }
611
612        if (!$user->perm->refuseAllUserRights($userId)) {
613            return $this->json(['error' => Translation::get(key: 'ad_msg_mysqlerr')], Response::HTTP_BAD_REQUEST);
614        }
615
616        foreach ($userRights as $userRight) {
617            $user->perm->grantUserRight($userId, (int) $userRight);
618        }
619
620        $this->adminLog->log($this->currentUser, AdminLogType::USER_CHANGE_PERMISSIONS->value . ':' . $userId);
621
622        $user->terminateSessionId();
623        $success =
624            Translation::getString(key: 'ad_msg_savedsuc_1')
625            . ' "'
626            . Strings::htmlentities($user->getLogin(), ENT_QUOTES)
627            . '" '
628            . Translation::getString(key: 'ad_msg_savedsuc_2');
629
630        return $this->json(['success' => $success], Response::HTTP_OK);
631    }
632}

Inherited from phpMyFAQ\Controller\Administration\Api\AbstractAdministrationApiController

31    protected function initializeFromContainer(): void
32    {
33        parent::initializeFromContainer();
34
35        $adminLog = $this->container->get(id: 'phpmyfaq.admin.admin-log');
36        if (!$adminLog instanceof AdminLog) {
37            throw new \LogicException('AdminLog service not found in container.');
38        }
39
40        $this->adminLog = $adminLog;
41    }

Inherited from phpMyFAQ\Controller\AbstractController

93    public function setContainer(ContainerInterface $container): void
94    {
95        $this->container = $container;
96        $this->initializeFromContainer();
97    }
137    public function render(string $file, array $context = [], ?Response $response = null): Response
138    {
139        $response ??= new Response();
140        $twigWrapper = $this->getTwigWrapper();
141        $templateWrapper = $twigWrapper->loadTemplate($file);
142
143        $response->setContent($templateWrapper->render($context));
144
145        return $response;
146    }
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    }
347    protected function userHasPermission(PermissionType $permissionType): void
348    {
349        if (!$this->currentUser->isLoggedIn()) {
350            throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
351        }
352
353        $currentUser = $this->currentUser;
354        if (!$currentUser?->perm->hasPermission($currentUser->getUserId(), $permissionType->value)) {
355            throw new ForbiddenException(message: sprintf('User has no "%s" permission.', $permissionType->name));
356        }
357    }
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    }