Passed
Pull Request — master (#6217)
by Yannick
15:43 queued 05:10
created

AccountController   B

Complexity

Total Complexity 50

Size/Duplication

Total Lines 265
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 157
c 2
b 0
f 0
dl 0
loc 265
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
A encryptTOTPSecret() 0 7 1
A isTOTPValid() 0 6 1
F changePassword() 0 145 27

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
        if ($user->getMfaEnabled() && 'TOTP' === $user->getMfaService() && $user->getMfaSecret()) {
112
            $decryptedSecret = $this->decryptTOTPSecret($user->getMfaSecret(), $_ENV['APP_SECRET']);
113
            $totp = TOTP::create($decryptedSecret);
114
            $portalName = $settingsManager->getSetting('platform.institution');
115
            $totp->setLabel($portalName . ' - '. $user->getEmail());
116
            $qrCodeResult = Builder::create()
117
                ->writer(new PngWriter())
118
                ->data($totp->getProvisioningUri())
119
                ->encoding(new Encoding('UTF-8'))
120
                ->errorCorrectionLevel(new ErrorCorrectionLevelHigh())
121
                ->size(300)
122
                ->margin(10)
123
                ->build()
124
            ;
125
126
            $qrCodeBase64 = base64_encode($qrCodeResult->getString());
127
            $showQRCode = true;
128
        }
129
130
        if ($form->isSubmitted() && $form->isValid()) {
131
            $submittedToken = $request->request->get('_token');
132
133
            if (!$csrfTokenManager->isTokenValid(new CsrfToken('change_password', $submittedToken))) {
134
                $form->addError(new FormError($this->translator->trans('CSRF token is invalid. Please try again.')));
135
            } else {
136
                $currentPassword = $form->get('currentPassword')->getData();
137
                $newPassword = $form->get('newPassword')->getData();
138
                $confirmPassword = $form->get('confirmPassword')->getData();
139
                $enable2FA = $form->get('enable2FA')->getData();
140
141
                if ($user->getMfaEnabled()) {
142
                    $enteredCode = $form->get('confirm2FACode')->getData();
143
                    if (empty($enteredCode) || !$this->isTOTPValid($user, $enteredCode)) {
144
                        $form->get('confirm2FACode')->addError(new FormError('The 2FA code is invalid.'));
145
                        return $this->render('@ChamiloCore/Account/change_password.html.twig', [
146
                            'form' => $form->createView(),
147
                            'qrCode' => $qrCodeBase64,
148
                            'user' => $user,
149
                            'showQRCode' => $qrCodeBase64 !== null || ($form->isSubmitted() && $form->get('enable2FA')->getData()),
150
                        ]);
151
                    }
152
                }
153
154
                if ($enable2FA && !$user->getMfaSecret()) {
155
                    $session = $request->getSession();
156
157
                    if (!$session->has('temporary_mfa_secret')) {
158
                        $totp = TOTP::create();
159
                        $secret = $totp->getSecret();
160
                        $session->set('temporary_mfa_secret', $secret);
161
                    } else {
162
                        $secret = $session->get('temporary_mfa_secret');
163
                        $totp = TOTP::create($secret);
164
                    }
165
166
                    $portalName = $settingsManager->getSetting('platform.institution');
167
                    $totp->setLabel($portalName . ' - '. $user->getEmail());
168
169
                    $qrCodeResult = Builder::create()
170
                        ->writer(new PngWriter())
171
                        ->data($totp->getProvisioningUri())
172
                        ->encoding(new Encoding('UTF-8'))
173
                        ->errorCorrectionLevel(new ErrorCorrectionLevelHigh())
174
                        ->size(300)
175
                        ->margin(10)
176
                        ->build()
177
                    ;
178
179
                    $qrCodeBase64 = base64_encode($qrCodeResult->getString());
180
                    $enteredCode = $form->get('confirm2FACode')->getData();
181
182
                    if (!$enteredCode) {
183
                        return $this->render('@ChamiloCore/Account/change_password.html.twig', [
184
                            'form' => $form->createView(),
185
                            'qrCode' => $qrCodeBase64,
186
                            'user' => $user,
187
                            'showQRCode' => true,
188
                        ]);
189
                    }
190
191
                    if (!$totp->verify($enteredCode)) {
192
                        $form->get('confirm2FACode')->addError(new FormError('The code is incorrect.'));
193
                        return $this->render('@ChamiloCore/Account/change_password.html.twig', [
194
                            'form' => $form->createView(),
195
                            'qrCode' => $qrCodeBase64,
196
                            'user' => $user,
197
                            'showQRCode' => true,
198
                        ]);
199
                    }
200
201
                    $encryptedSecret = $this->encryptTOTPSecret($secret, $_ENV['APP_SECRET']);
202
                    $user->setMfaSecret($encryptedSecret);
203
                    $user->setMfaEnabled(true);
204
                    $user->setMfaService('TOTP');
205
                    $userRepository->updateUser($user);
206
                    $session->remove('temporary_mfa_secret');
207
                    $this->addFlash('success', '2FA activated successfully.');
208
209
                    return $this->redirectToRoute('chamilo_core_account_home');
210
                }
211
                if (!$enable2FA) {
212
                    $user->setMfaEnabled(false);
213
                    $user->setMfaSecret(null);
214
                    $userRepository->updateUser($user);
215
                    $this->addFlash('success', '2FA disabled successfully.');
216
                }
217
218
                if ($newPassword || $confirmPassword || $currentPassword) {
219
                    if (!$userRepository->isPasswordValid($user, $currentPassword)) {
220
                        $form->get('currentPassword')->addError(new FormError($this->translator->trans('The current password is incorrect')));
221
                    } elseif ($newPassword !== $confirmPassword) {
222
                        $form->get('confirmPassword')->addError(new FormError($this->translator->trans('Passwords do not match')));
223
                    } else {
224
                        $user->setPlainPassword($newPassword);
225
                        $userRepository->updateUser($user);
226
                        $this->addFlash('success', 'Password updated successfully');
227
                    }
228
                }
229
230
                return $this->redirectToRoute('chamilo_core_account_home');
231
            }
232
        }
233
234
        return $this->render('@ChamiloCore/Account/change_password.html.twig', [
235
            'form' => $form->createView(),
236
            'qrCode' => $qrCodeBase64,
237
            'user' => $user,
238
            'showQRCode' => $qrCodeBase64 !== null || ($form->isSubmitted() && $form->get('enable2FA')->getData()),
239
        ]);
240
    }
241
242
    /**
243
     * Encrypts the TOTP secret using AES-256-CBC encryption.
244
     */
245
    private function encryptTOTPSecret(string $secret, string $encryptionKey): string
246
    {
247
        $cipherMethod = 'aes-256-cbc';
248
        $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length($cipherMethod));
249
        $encryptedSecret = openssl_encrypt($secret, $cipherMethod, $encryptionKey, 0, $iv);
250
251
        return base64_encode($iv.'::'.$encryptedSecret);
252
    }
253
254
    /**
255
     * Validates the provided TOTP code for the given user.
256
     */
257
    private function isTOTPValid(User $user, string $totpCode): bool
258
    {
259
        $decryptedSecret = $this->decryptTOTPSecret($user->getMfaSecret(), $_ENV['APP_SECRET']);
260
        $totp = TOTP::create($decryptedSecret);
261
262
        return $totp->verify($totpCode);
263
    }
264
265
    /**
266
     * Decrypts the TOTP secret using AES-256-CBC decryption.
267
     */
268
    private function decryptTOTPSecret(string $encryptedSecret, string $encryptionKey): string
269
    {
270
        $cipherMethod = 'aes-256-cbc';
271
        list($iv, $encryptedData) = explode('::', base64_decode($encryptedSecret), 2);
272
273
        return openssl_decrypt($encryptedData, $cipherMethod, $encryptionKey, 0, $iv);
274
    }
275
276
    /**
277
     * Validate the password against the same requirements as the client-side validation.
278
     */
279
    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...
280
    {
281
        $errors = [];
282
        $minRequirements = Security::getPasswordRequirements()['min'];
283
284
        if (\strlen($password) < $minRequirements['length']) {
285
            $errors[] = $this->translator->trans('Password must be at least %length% characters long.', ['%length%' => $minRequirements['length']]);
286
        }
287
        if ($minRequirements['lowercase'] > 0 && !preg_match('/[a-z]/', $password)) {
288
            $errors[] = $this->translator->trans('Password must contain at least %count% lowercase characters.', ['%count%' => $minRequirements['lowercase']]);
289
        }
290
        if ($minRequirements['uppercase'] > 0 && !preg_match('/[A-Z]/', $password)) {
291
            $errors[] = $this->translator->trans('Password must contain at least %count% uppercase characters.', ['%count%' => $minRequirements['uppercase']]);
292
        }
293
        if ($minRequirements['numeric'] > 0 && !preg_match('/[0-9]/', $password)) {
294
            $errors[] = $this->translator->trans('Password must contain at least %count% numerical (0-9) characters.', ['%count%' => $minRequirements['numeric']]);
295
        }
296
        if ($minRequirements['specials'] > 0 && !preg_match('/[\W]/', $password)) {
297
            $errors[] = $this->translator->trans('Password must contain at least %count% special characters.', ['%count%' => $minRequirements['specials']]);
298
        }
299
300
        return $errors;
301
    }
302
}
303