Passed
Pull Request — master (#6463)
by
unknown
07:57
created

AccountController   B

Complexity

Total Complexity 48

Size/Duplication

Total Lines 253
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 149
c 0
b 0
f 0
dl 0
loc 253
rs 8.5599
wmc 48

6 Methods

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