Passed
Pull Request — master (#6217)
by
unknown
08:44
created

AccountController::isTOTPValid()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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