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

AccountController   B

Complexity

Total Complexity 50

Size/Duplication

Total Lines 266
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 158
c 2
b 0
f 0
dl 0
loc 266
rs 8.4
wmc 50

7 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 4 1
B edit() 0 45 9
A decryptTOTPSecret() 0 6 1
B validatePassword() 0 22 10
F changePassword() 0 146 27
A encryptTOTPSecret() 0 7 1
A isTOTPValid() 0 6 1

How to fix   Complexity   

Complex Class

Complex classes like AccountController often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use AccountController, and based on these observations, apply Extract Interface, too.

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