Passed
Pull Request — master (#6463)
by
unknown
08:02
created

AccountController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 0

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 0
nc 1
nop 2
dl 0
loc 4
rs 10
c 0
b 0
f 0
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\PasswordHasher\Hasher\UserPasswordHasherInterface;
28
use Symfony\Component\Routing\Attribute\Route;
29
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
30
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
31
use Symfony\Component\Security\Core\User\UserInterface;
32
use Symfony\Component\Security\Csrf\CsrfToken;
33
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
34
use Symfony\Contracts\Translation\TranslatorInterface;
35
36
/**
37
 * @author Julio Montoya <[email protected]>
38
 */
39
#[Route('/account')]
40
class AccountController extends BaseController
41
{
42
    use ControllerTrait;
43
44
    public function __construct(
45
        private readonly UserHelper $userHelper,
46
        private readonly TranslatorInterface $translator
47
    ) {}
48
49
    #[Route('/edit', name: 'chamilo_core_account_edit', methods: ['GET', 'POST'])]
50
    public function edit(
51
        Request $request,
52
        UserRepository $userRepository,
53
        IllustrationRepository $illustrationRepo,
54
        SettingsManager $settingsManager
55
    ): Response {
56
        $user = $this->userHelper->getCurrent();
57
58
        if (!\is_object($user) || !$user instanceof UserInterface) {
59
            throw $this->createAccessDeniedException('This user does not have access to this section');
60
        }
61
62
        /** @var User $user */
63
        $form = $this->createForm(ProfileType::class, $user);
64
        $form->setData($user);
65
        $form->handleRequest($request);
66
67
        if ($form->isSubmitted() && $form->isValid()) {
68
            if ($form->has('illustration')) {
69
                $illustration = $form['illustration']->getData();
70
                if ($illustration) {
71
                    $illustrationRepo->deleteIllustration($user);
72
                    $illustrationRepo->addIllustration($user, $user, $illustration);
73
                }
74
            }
75
76
            if ($form->has('password')) {
77
                $password = $form['password']->getData();
78
                if ($password) {
79
                    $user->setPlainPassword($password);
80
                    $user->setPasswordUpdateAt(new \DateTimeImmutable());
81
                }
82
            }
83
84
            $showTermsIfProfileCompleted = ('true' === $settingsManager->getSetting('show_terms_if_profile_completed'));
85
            $user->setProfileCompleted($showTermsIfProfileCompleted);
86
87
            $userRepository->updateUser($user);
88
            $this->addFlash('success', $this->trans('Updated'));
89
            $url = $this->generateUrl('chamilo_core_account_home');
90
91
            $request->getSession()->set('_locale_user', $user->getLocale());
92
93
            return new RedirectResponse($url);
94
        }
95
96
        return $this->render('@ChamiloCore/Account/edit.html.twig', [
97
            'form' => $form->createView(),
98
            'user' => $user,
99
        ]);
100
    }
101
102
    #[Route('/change-password', name: 'chamilo_core_account_change_password', methods: ['GET', 'POST'])]
103
    public function changePassword(
104
        Request $request,
105
        UserRepository $userRepository,
106
        CsrfTokenManagerInterface $csrfTokenManager,
107
        SettingsManager $settingsManager,
108
        UserPasswordHasherInterface $passwordHasher,
109
        TokenStorageInterface $tokenStorage,
110
    ): Response {
111
        /** @var ?User $user */
112
        $user = $this->getUser();
113
114
        if (!$user || !$user instanceof UserInterface) {
115
            $userId = $request->query->get('userId');
116
            //error_log("User not logged in. Received userId from query: " . $userId);
117
118
            if (!$userId || !ctype_digit($userId)) {
119
                //error_log("Access denied: Missing or invalid userId.");
120
                throw $this->createAccessDeniedException('This user does not have access to this section.');
121
            }
122
123
            $user = $userRepository->find((int)$userId);
124
125
            if (!$user || !$user instanceof UserInterface) {
126
                //error_log("Access denied: User not found with ID $userId");
127
                throw $this->createAccessDeniedException('User not found or invalid.');
128
            }
129
130
            //error_log("Loaded user by ID: " . $user->getId());
131
        }
132
133
        $isRotation = $request->query->getBoolean('rotate', false);
134
135
        $form = $this->createForm(ChangePasswordType::class, [
136
            'enable2FA' => $user->getMfaEnabled(),
137
        ], [
138
            'user' => $user,
139
            'portal_name' => $settingsManager->getSetting('platform.institution'),
140
            'password_hasher' => $passwordHasher,
141
            'enable_2fa_field' => !$isRotation,
142
        ]);
143
        $form->handleRequest($request);
144
145
        $session = $request->getSession();
146
        $qrCodeBase64 = null;
147
        $showQRCode = false;
148
149
        // Build QR code preview if user opts to enable 2FA but hasn't saved yet
150
        if (
151
            $form->isSubmitted()
152
            && $form->has('enable2FA')
153
            && $form->get('enable2FA')->getData()
154
            && !$user->getMfaSecret()
155
        ) {
156
            if (!$session->has('temporary_mfa_secret')) {
157
                $totp = TOTP::create();
158
                $secret = $totp->getSecret();
159
                $session->set('temporary_mfa_secret', $secret);
160
            } else {
161
                $secret = $session->get('temporary_mfa_secret');
162
            }
163
164
            $totp = TOTP::create($secret);
165
            $portalName = $settingsManager->getSetting('platform.institution');
166
            $totp->setLabel($portalName . ' - ' . $user->getEmail());
167
168
            $qrCodeResult = Builder::create()
169
                ->writer(new PngWriter())
170
                ->data($totp->getProvisioningUri())
171
                ->encoding(new Encoding('UTF-8'))
172
                ->errorCorrectionLevel(new ErrorCorrectionLevelHigh())
173
                ->size(300)
174
                ->margin(10)
175
                ->build();
176
177
            $qrCodeBase64 = base64_encode($qrCodeResult->getString());
178
            $showQRCode = true;
179
        }
180
181
        if ($form->isSubmitted()) {
182
            if ($form->isValid()) {
183
                $submittedToken = $request->request->get('_token');
184
                if (!$csrfTokenManager->isTokenValid(new CsrfToken('change_password', $submittedToken))) {
185
                    $form->addError(new FormError($this->translator->trans('CSRF token is invalid. Please try again.')));
186
                } else {
187
                    $currentPassword = $form->get('currentPassword')->getData();
188
                    $newPassword = $form->get('newPassword')->getData();
189
                    $confirmPassword = $form->get('confirmPassword')->getData();
190
                    $enable2FA = !$isRotation && $form->has('enable2FA')
191
                        ? $form->get('enable2FA')->getData()
192
                        : false;
193
194
                    if ($enable2FA && !$user->getMfaSecret()) {
195
                        $secret = $session->get('temporary_mfa_secret');
196
                        $encryptedSecret = $this->encryptTOTPSecret($secret, $_ENV['APP_SECRET']);
197
                        $user->setMfaSecret($encryptedSecret);
198
                        $user->setMfaEnabled(true);
199
                        $user->setMfaService('TOTP');
200
                        $userRepository->updateUser($user);
201
202
                        $session->remove('temporary_mfa_secret');
203
204
                        $this->addFlash('success', '2FA activated successfully.');
205
206
                        return $this->redirectToRoute('chamilo_core_account_home');
207
                    }
208
209
                    if (!$isRotation && !$enable2FA && $user->getMfaEnabled()) {
210
                        $user->setMfaEnabled(false);
211
                        $user->setMfaSecret(null);
212
                        $userRepository->updateUser($user);
213
                        $this->addFlash('success', '2FA disabled successfully.');
214
                    }
215
216
                    if ($newPassword || $confirmPassword || $currentPassword) {
217
                        if (!$userRepository->isPasswordValid($user, $currentPassword)) {
218
                            $form->get('currentPassword')->addError(new FormError(
219
                                $this->translator->trans('The current password is incorrect')
220
                            ));
221
                        } elseif ($newPassword !== $confirmPassword) {
222
                            $form->get('confirmPassword')->addError(new FormError(
223
                                $this->translator->trans('Passwords do not match')
224
                            ));
225
                        } else {
226
                            $user->setPlainPassword($newPassword);
227
                            $user->setPasswordUpdateAt(new \DateTimeImmutable());
228
                            $userRepository->updateUser($user);
229
                            $this->addFlash('success', 'Password updated successfully.');
230
231
                            // Re-login if the user was not logged
232
                            if (!$this->getUser()) {
233
                                $token = new UsernamePasswordToken(
234
                                    $user,
235
                                    'main',
236
                                    $user->getRoles()
237
                                );
238
                                $tokenStorage->setToken($token);
239
                                $request->getSession()->set('_security_main', serialize($token));
240
                            }
241
242
                            return $this->redirectToRoute('chamilo_core_account_home');
243
                        }
244
                    }
245
                }
246
            } else {
247
                error_log("Form is NOT valid.");
248
            }
249
        } else {
250
            error_log("Form NOT submitted yet.");
251
        }
252
253
        return $this->render('@ChamiloCore/Account/change_password.html.twig', [
254
            'form' => $form->createView(),
255
            'qrCode' => $qrCodeBase64,
256
            'user' => $user,
257
            'showQRCode' => $showQRCode,
258
        ]);
259
    }
260
261
    /**
262
     * Encrypts the TOTP secret using AES-256-CBC encryption.
263
     */
264
    private function encryptTOTPSecret(string $secret, string $encryptionKey): string
265
    {
266
        $cipherMethod = 'aes-256-cbc';
267
        $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length($cipherMethod));
268
        $encryptedSecret = openssl_encrypt($secret, $cipherMethod, $encryptionKey, 0, $iv);
269
270
        return base64_encode($iv . '::' . $encryptedSecret);
271
    }
272
273
    /**
274
     * Decrypts the TOTP secret using AES-256-CBC decryption.
275
     */
276
    private function decryptTOTPSecret(string $encryptedSecret, string $encryptionKey): string
0 ignored issues
show
Unused Code introduced by
The method decryptTOTPSecret() 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...
277
    {
278
        $cipherMethod = 'aes-256-cbc';
279
        list($iv, $encryptedData) = explode('::', base64_decode($encryptedSecret), 2);
280
281
        return openssl_decrypt($encryptedData, $cipherMethod, $encryptionKey, 0, $iv);
282
    }
283
284
    /**
285
     * Validate the password against the same requirements as the client-side validation.
286
     */
287
    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...
288
    {
289
        $errors = [];
290
        $minRequirements = Security::getPasswordRequirements()['min'];
291
292
        if (\strlen($password) < $minRequirements['length']) {
293
            $errors[] = $this->translator->trans('Password must be at least %length% characters long.', ['%length%' => $minRequirements['length']]);
294
        }
295
        if ($minRequirements['lowercase'] > 0 && !preg_match('/[a-z]/', $password)) {
296
            $errors[] = $this->translator->trans('Password must contain at least %count% lowercase characters.', ['%count%' => $minRequirements['lowercase']]);
297
        }
298
        if ($minRequirements['uppercase'] > 0 && !preg_match('/[A-Z]/', $password)) {
299
            $errors[] = $this->translator->trans('Password must contain at least %count% uppercase characters.', ['%count%' => $minRequirements['uppercase']]);
300
        }
301
        if ($minRequirements['numeric'] > 0 && !preg_match('/[0-9]/', $password)) {
302
            $errors[] = $this->translator->trans('Password must contain at least %count% numerical (0-9) characters.', ['%count%' => $minRequirements['numeric']]);
303
        }
304
        if ($minRequirements['specials'] > 0 && !preg_match('/[\W]/', $password)) {
305
            $errors[] = $this->translator->trans('Password must contain at least %count% special characters.', ['%count%' => $minRequirements['specials']]);
306
        }
307
308
        return $errors;
309
    }
310
}
311