Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.25% covered (success)
98.25%
56 / 57
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
PasswordChangeController
98.25% covered (success)
98.25%
56 / 57
75.00% covered (warning)
75.00%
3 / 4
13
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
 index
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 update
97.62% covered (success)
97.62%
41 / 42
0.00% covered (danger)
0.00%
0 / 1
10
 getBaseTemplateVars
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The Change Password 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-11-23
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Administration;
21
22use phpMyFAQ\Auth;
23use phpMyFAQ\Auth\AuthException;
24use phpMyFAQ\Core\Exception;
25use phpMyFAQ\Enums\PermissionType;
26use phpMyFAQ\Filter;
27use phpMyFAQ\Session\Token;
28use phpMyFAQ\Translation;
29use Symfony\Component\HttpFoundation\Request;
30use Symfony\Component\HttpFoundation\Response;
31use Symfony\Component\Routing\Attribute\Route;
32use Twig\Error\LoaderError;
33
34final class PasswordChangeController extends AbstractAdministrationController
35{
36    public function __construct(
37        private readonly Auth $auth,
38    ) {
39        parent::__construct();
40    }
41
42    /**
43     * @throws LoaderError
44     * @throws Exception
45     * @throws \Exception
46     */
47    #[Route(path: '/password/change', name: 'admin.password.change', methods: ['GET'])]
48    public function index(Request $request): Response
49    {
50        $this->userHasPermission(PermissionType::PASSWORD_CHANGE);
51
52        return $this->render('@admin/user/password.twig', [
53            ...$this->getHeader($request),
54            ...$this->getFooter(),
55            ...$this->getBaseTemplateVars(),
56        ]);
57    }
58
59    /**
60     * @throws Exception
61     * @throws LoaderError
62     * @throws \Exception
63     */
64    #[Route(path: '/password/update', name: 'admin.password.update', methods: ['POST'])]
65    public function update(Request $request): Response
66    {
67        $this->userHasPermission(PermissionType::PASSWORD_CHANGE);
68
69        $csrfToken = Filter::filterVar($request->request->get('pmf-csrf-token'), FILTER_SANITIZE_SPECIAL_CHARS);
70
71        if (!Token::getInstance($this->session)->verifyToken('password', $csrfToken)) {
72            throw new Exception('Invalid CSRF token');
73        }
74
75        $auth = $this->auth;
76        $authSource = $auth->selectAuth($this->currentUser->getAuthSource('name') ?? '');
77        $authSource->getEncryptionContainer((string) $this->currentUser->getAuthData('encType'));
78
79        $authSource->disableReadOnly();
80        if ($this->currentUser->getAuthData(key: 'readOnly')) {
81            $authSource->enableReadOnly();
82        }
83
84        $oldPassword = Filter::filterVar($request->request->get('faqpassword_old'), FILTER_SANITIZE_SPECIAL_CHARS);
85        $newPassword = Filter::filterVar($request->request->get('faqpassword'), FILTER_SANITIZE_SPECIAL_CHARS, '');
86        $retypedPassword = Filter::filterVar(
87            $request->request->get('faqpassword_confirm'),
88            FILTER_SANITIZE_SPECIAL_CHARS,
89        );
90
91        $newPasswordIsValid =
92            strlen((string) $newPassword) > 7
93            && strlen((string) $retypedPassword) > 7
94            && hash_equals((string) $newPassword, (string) $retypedPassword);
95
96        // checkCredentials() throws on an incorrect password instead of returning
97        // false, so treat any failure as "the current password is wrong". It must
98        // be verified exactly once, and never after the password has been changed.
99        $currentPasswordIsValid = false;
100        try {
101            $currentPasswordIsValid = $authSource->checkCredentials(
102                $this->currentUser->getLogin(),
103                (string) $oldPassword,
104            );
105        } catch (AuthException) {
106            $currentPasswordIsValid = false;
107        }
108
109        $passwordChanged =
110            $newPasswordIsValid && $currentPasswordIsValid && $this->currentUser->changePassword($newPassword);
111
112        $successMessage = '';
113        $errorMessage = '';
114        if ($passwordChanged) {
115            $successMessage = Translation::get(key: 'ad_passwdsuc');
116        }
117
118        if (!$passwordChanged) {
119            $errorMessage = Translation::get(key: 'ad_passwd_fail');
120        }
121
122        return $this->render('@admin/user/password.twig', [
123            ...$this->getHeader($request),
124            ...$this->getFooter(),
125            ...$this->getBaseTemplateVars(),
126            'successMessage' => $successMessage,
127            'errorMessage' => $errorMessage,
128        ]);
129    }
130
131    /**
132     * @throws \Exception
133     * @return array<string, mixed>
134     */
135    private function getBaseTemplateVars(): array
136    {
137        return [
138            'adminHeaderPasswordChange' => Translation::get(key: 'ad_passwd_cop'),
139            'csrfToken' => Token::getInstance($this->session)->getTokenString('password'),
140            'adminMsgOldPassword' => Translation::get(key: 'ad_passwd_old'),
141            'adminMsgNewPassword' => Translation::get(key: 'ad_passwd_new'),
142            'adminMsgNewPasswordConfirm' => Translation::get(key: 'ad_passwd_con'),
143            'adminMsgButtonNewPassword' => Translation::get(key: 'ad_passwd_change'),
144        ];
145    }
146}