Passed
Pull Request — master (#6463)
by
unknown
07:57
created

AccountController::validatePassword()   B

Complexity

Conditions 10
Paths 32

Size

Total Lines 22
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 10
eloc 13
nc 32
nop 1
dl 0
loc 22
rs 7.6666
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
namespace Chamilo\CoreBundle\Controller;
8
9
use Chamilo\CoreBundle\Entity\User;
10
use Chamilo\CoreBundle\Form\ChangePasswordType;
11
use Chamilo\CoreBundle\Form\ProfileType;
12
use Chamilo\CoreBundle\Helpers\UserHelper;
13
use Chamilo\CoreBundle\Repository\Node\IllustrationRepository;
14
use Chamilo\CoreBundle\Repository\Node\UserRepository;
15
use Chamilo\CoreBundle\Settings\SettingsManager;
16
use Chamilo\CoreBundle\Traits\ControllerTrait;
17
use Endroid\QrCode\Builder\Builder;
18
use Endroid\QrCode\Encoding\Encoding;
19
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh;
20
use Endroid\QrCode\Writer\PngWriter;
21
use OTPHP\TOTP;
22
use Security;
23
use Symfony\Component\Form\FormError;
24
use Symfony\Component\HttpFoundation\RedirectResponse;
25
use Symfony\Component\HttpFoundation\Request;
26
use Symfony\Component\HttpFoundation\Response;
27
use Symfony\Component\Routing\Attribute\Route;
28
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
29
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
30
use Symfony\Component\Security\Core\User\UserInterface;
31
use Symfony\Component\Security\Csrf\CsrfToken;
32
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
33
use Symfony\Contracts\Translation\TranslatorInterface;
34
35
/**
36
 * @author Julio Montoya <[email protected]>
37
 */
38
#[Route('/account')]
39
class AccountController extends BaseController
40
{
41
    use ControllerTrait;
42
43
    public function __construct(
44
        private readonly UserHelper $userHelper,
45
        private readonly TranslatorInterface $translator
46
    ) {}
47
48
    #[Route('/edit', name: 'chamilo_core_account_edit', methods: ['GET', 'POST'])]
49
    public function edit(Request $request, UserRepository $userRepository, IllustrationRepository $illustrationRepo, SettingsManager $settingsManager): Response
50
    {
51
        $user = $this->userHelper->getCurrent();
52
53
        if (!\is_object($user) || !$user instanceof UserInterface) {
54
            throw $this->createAccessDeniedException('This user does not have access to this section');
55
        }
56
57
        /** @var User $user */
58
        $form = $this->createForm(ProfileType::class, $user);
59
        $form->setData($user);
60
        $form->handleRequest($request);
61
62
        if ($form->isSubmitted() && $form->isValid()) {
63
            if ($form->has('illustration')) {
64
                $illustration = $form['illustration']->getData();
65
                if ($illustration) {
66
                    $illustrationRepo->deleteIllustration($user);
67
                    $illustrationRepo->addIllustration($user, $user, $illustration);
68
                }
69
            }
70
71
            if ($form->has('password')) {
72
                $password = $form['password']->getData();
73
                if ($password) {
74
                    $user->setPlainPassword($password);
75
                    $user->setPasswordUpdateAt(new \DateTimeImmutable());
76
                }
77
            }
78
79
            $showTermsIfProfileCompleted = ('true' === $settingsManager->getSetting('show_terms_if_profile_completed'));
80
            $user->setProfileCompleted($showTermsIfProfileCompleted);
81
82
            $userRepository->updateUser($user);
83
            $this->addFlash('success', $this->trans('Updated'));
84
            $url = $this->generateUrl('chamilo_core_account_home');
85
86
            $request->getSession()->set('_locale_user', $user->getLocale());
87
88
            return new RedirectResponse($url);
89
        }
90
91
        return $this->render('@ChamiloCore/Account/edit.html.twig', [
92
            'form' => $form->createView(),
93
            'user' => $user,
94
        ]);
95
    }
96
97
    #[Route('/change-password', name: 'chamilo_core_account_change_password', methods: ['GET', 'POST'])]
98
    public function changePassword(
99
        Request $request,
100
        UserRepository $userRepository,
101
        CsrfTokenManagerInterface $csrfTokenManager,
102
        TokenStorageInterface $tokenStorage,
103
    ): Response {
104
        /** @var ?User $user */
105
        $user = $this->getUser();
106
107
        if (!$user || !$user instanceof UserInterface) {
108
            $userId = $request->query->get('userId');
109
            error_log("User not logged in. Received userId from query: " . $userId);
110
111
            if (!$userId || !ctype_digit($userId)) {
112
                error_log("Access denied: Missing or invalid userId.");
113
                throw $this->createAccessDeniedException('This user does not have access to this section.');
114
            }
115
116
            $user = $userRepository->find((int)$userId);
117
118
            if (!$user || !$user instanceof UserInterface) {
119
                error_log("Access denied: User not found with ID $userId");
120
                throw $this->createAccessDeniedException('User not found or invalid.');
121
            }
122
123
            error_log("Loaded user by ID: " . $user->getId());
124
        }
125
126
        $isRotation = $request->query->getBoolean('rotate', false);
127
128
        $form = $this->createForm(ChangePasswordType::class, [
129
            'enable2FA' => $user->getMfaEnabled(),
130
        ], [
131
            'enable_2fa_field' => !$isRotation,
132
        ]);
133
        $form->handleRequest($request);
134
135
        $qrCodeBase64 = null;
136
        if ($user->getMfaEnabled() && 'TOTP' === $user->getMfaService() && $user->getMfaSecret()) {
137
            $decryptedSecret = $this->decryptTOTPSecret($user->getMfaSecret(), $_ENV['APP_SECRET']);
138
            $totp = TOTP::create($decryptedSecret);
139
            $totp->setLabel($user->getEmail());
140
141
            $qrCodeResult = Builder::create()
142
                ->writer(new PngWriter())
143
                ->data($totp->getProvisioningUri())
144
                ->encoding(new Encoding('UTF-8'))
145
                ->errorCorrectionLevel(new ErrorCorrectionLevelHigh())
146
                ->size(300)
147
                ->margin(10)
148
                ->build();
149
150
            $qrCodeBase64 = base64_encode($qrCodeResult->getString());
151
        }
152
153
        if ($form->isSubmitted()) {
154
            if ($form->isValid()) {
155
                $submittedToken = $request->request->get('_token');
156
                if (!$csrfTokenManager->isTokenValid(new CsrfToken('change_password', $submittedToken))) {
157
                    $form->addError(new FormError($this->translator->trans('CSRF token is invalid. Please try again.')));
158
                } else {
159
                    $currentPassword = $form->get('currentPassword')->getData();
160
                    $newPassword = $form->get('newPassword')->getData();
161
                    $confirmPassword = $form->get('confirmPassword')->getData();
162
                    $enable2FA = !$isRotation && $form->has('enable2FA')
163
                        ? $form->get('enable2FA')->getData()
164
                        : false;
165
166
                    if ($enable2FA && !$user->getMfaSecret()) {
167
                        $totp = TOTP::create();
168
                        $totp->setLabel($user->getEmail());
169
                        $encryptedSecret = $this->encryptTOTPSecret($totp->getSecret(), $_ENV['APP_SECRET']);
170
                        $user->setMfaSecret($encryptedSecret);
171
                        $user->setMfaEnabled(true);
172
                        $user->setMfaService('TOTP');
173
                        $userRepository->updateUser($user);
174
175
                        $qrCodeResult = Builder::create()
176
                            ->writer(new PngWriter())
177
                            ->data($totp->getProvisioningUri())
178
                            ->encoding(new Encoding('UTF-8'))
179
                            ->errorCorrectionLevel(new ErrorCorrectionLevelHigh())
180
                            ->size(300)
181
                            ->margin(10)
182
                            ->build();
183
184
                        $qrCodeBase64 = base64_encode($qrCodeResult->getString());
185
186
                        return $this->render('@ChamiloCore/Account/change_password.html.twig', [
187
                            'form' => $form->createView(),
188
                            'qrCode' => $qrCodeBase64,
189
                            'user' => $user,
190
                        ]);
191
                    }
192
193
                    if (!$isRotation && !$enable2FA && $user->getMfaEnabled()) {
194
                        $user->setMfaEnabled(false);
195
                        $user->setMfaSecret(null);
196
                        $userRepository->updateUser($user);
197
                        $this->addFlash('success', '2FA disabled successfully.');
198
                    }
199
200
                    if ($newPassword || $confirmPassword || $currentPassword) {
201
                        if (!$userRepository->isPasswordValid($user, $currentPassword)) {
202
                            $form->get('currentPassword')->addError(new FormError(
203
                                $this->translator->trans('The current password is incorrect.')
204
                            ));
205
                        } elseif ($newPassword !== $confirmPassword) {
206
                            $form->get('confirmPassword')->addError(new FormError(
207
                                $this->translator->trans('Passwords do not match.')
208
                            ));
209
                        } else {
210
                            $user->setPlainPassword($newPassword);
211
                            $user->setPasswordUpdateAt(new \DateTimeImmutable());
212
                            $userRepository->updateUser($user);
213
                            $this->addFlash('success', 'Password updated successfully');
214
215
                            if (!$this->getUser()) {
216
                                $token = new UsernamePasswordToken(
217
                                    $user,
218
                                    'main',
219
                                    $user->getRoles()
220
                                );
221
                                $tokenStorage->setToken($token);
222
                                $request->getSession()->set('_security_main', serialize($token));
223
                            }
224
225
                            return $this->redirectToRoute('chamilo_core_account_home');
226
                        }
227
                    }
228
                }
229
            } else {
230
                error_log("Form is NOT valid.");
231
            }
232
        } else {
233
            error_log("Form NOT submitted yet.");
234
        }
235
236
        return $this->render('@ChamiloCore/Account/change_password.html.twig', [
237
            'form' => $form->createView(),
238
            'qrCode' => $qrCodeBase64,
239
            'user' => $user,
240
        ]);
241
    }
242
243
    /**
244
     * Encrypts the TOTP secret using AES-256-CBC encryption.
245
     */
246
    private function encryptTOTPSecret(string $secret, string $encryptionKey): string
247
    {
248
        $cipherMethod = 'aes-256-cbc';
249
        $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length($cipherMethod));
250
        $encryptedSecret = openssl_encrypt($secret, $cipherMethod, $encryptionKey, 0, $iv);
251
252
        return base64_encode($iv.'::'.$encryptedSecret);
253
    }
254
255
    /**
256
     * Decrypts the TOTP secret using AES-256-CBC decryption.
257
     */
258
    private function decryptTOTPSecret(string $encryptedSecret, string $encryptionKey): string
259
    {
260
        $cipherMethod = 'aes-256-cbc';
261
        list($iv, $encryptedData) = explode('::', base64_decode($encryptedSecret), 2);
262
263
        return openssl_decrypt($encryptedData, $cipherMethod, $encryptionKey, 0, $iv);
264
    }
265
266
    /**
267
     * Validate the password against the same requirements as the client-side validation.
268
     */
269
    private function validatePassword(string $password): array
0 ignored issues
show
Unused Code introduced by
The method validatePassword() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
270
    {
271
        $errors = [];
272
        $minRequirements = Security::getPasswordRequirements()['min'];
273
274
        if (\strlen($password) < $minRequirements['length']) {
275
            $errors[] = $this->translator->trans('Password must be at least %length% characters long.', ['%length%' => $minRequirements['length']]);
276
        }
277
        if ($minRequirements['lowercase'] > 0 && !preg_match('/[a-z]/', $password)) {
278
            $errors[] = $this->translator->trans('Password must contain at least %count% lowercase characters.', ['%count%' => $minRequirements['lowercase']]);
279
        }
280
        if ($minRequirements['uppercase'] > 0 && !preg_match('/[A-Z]/', $password)) {
281
            $errors[] = $this->translator->trans('Password must contain at least %count% uppercase characters.', ['%count%' => $minRequirements['uppercase']]);
282
        }
283
        if ($minRequirements['numeric'] > 0 && !preg_match('/[0-9]/', $password)) {
284
            $errors[] = $this->translator->trans('Password must contain at least %count% numerical (0-9) characters.', ['%count%' => $minRequirements['numeric']]);
285
        }
286
        if ($minRequirements['specials'] > 0 && !preg_match('/[\W]/', $password)) {
287
            $errors[] = $this->translator->trans('Password must contain at least %count% special characters.', ['%count%' => $minRequirements['specials']]);
288
        }
289
290
        return $errors;
291
    }
292
}
293