Passed
Pull Request — master (#6800)
by
unknown
08:35
created

AccountController   C

Complexity

Total Complexity 56

Size/Duplication

Total Lines 275
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 153
dl 0
loc 275
rs 5.5199
c 0
b 0
f 0
wmc 56

6 Methods

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