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

AccountController::edit()   B

Complexity

Conditions 9
Paths 11

Size

Total Lines 45
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 9
eloc 27
nc 11
nop 4
dl 0
loc 45
rs 8.0555
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\HttpFoundation\RedirectResponse;
24
use Symfony\Component\HttpFoundation\Request;
25
use Symfony\Component\HttpFoundation\Response;
26
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
27
use Symfony\Component\Routing\Attribute\Route;
28
use Symfony\Component\Security\Core\User\UserInterface;
29
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
30
use Symfony\Contracts\Translation\TranslatorInterface;
31
32
/**
33
 * @author Julio Montoya <[email protected]>
34
 */
35
#[Route('/account')]
36
class AccountController extends BaseController
37
{
38
    use ControllerTrait;
39
40
    public function __construct(
41
        private readonly UserHelper $userHelper,
42
        private readonly TranslatorInterface $translator
43
    ) {}
44
45
    #[Route('/edit', name: 'chamilo_core_account_edit', methods: ['GET', 'POST'])]
46
    public function edit(Request $request, UserRepository $userRepository, IllustrationRepository $illustrationRepo, SettingsManager $settingsManager): Response
47
    {
48
        $user = $this->userHelper->getCurrent();
49
50
        if (!\is_object($user) || !$user instanceof UserInterface) {
51
            throw $this->createAccessDeniedException('This user does not have access to this section');
52
        }
53
54
        /** @var User $user */
55
        $form = $this->createForm(ProfileType::class, $user);
56
        $form->setData($user);
57
        $form->handleRequest($request);
58
59
        if ($form->isSubmitted() && $form->isValid()) {
60
            if ($form->has('illustration')) {
61
                $illustration = $form['illustration']->getData();
62
                if ($illustration) {
63
                    $illustrationRepo->deleteIllustration($user);
64
                    $illustrationRepo->addIllustration($user, $user, $illustration);
65
                }
66
            }
67
68
            if ($form->has('password')) {
69
                $password = $form['password']->getData();
70
                if ($password) {
71
                    $user->setPlainPassword($password);
72
                }
73
            }
74
75
            $showTermsIfProfileCompleted = ('true' === $settingsManager->getSetting('show_terms_if_profile_completed'));
76
            $user->setProfileCompleted($showTermsIfProfileCompleted);
77
78
            $userRepository->updateUser($user);
79
            $this->addFlash('success', $this->trans('Updated'));
80
            $url = $this->generateUrl('chamilo_core_account_home');
81
82
            $request->getSession()->set('_locale_user', $user->getLocale());
83
84
            return new RedirectResponse($url);
85
        }
86
87
        return $this->render('@ChamiloCore/Account/edit.html.twig', [
88
            'form' => $form->createView(),
89
            'user' => $user,
90
        ]);
91
    }
92
93
    #[Route('/change-password', name: 'chamilo_core_account_change_password', methods: ['GET', 'POST'])]
94
    public function changePassword(
95
        Request $request,
96
        UserRepository $userRepository,
97
        CsrfTokenManagerInterface $csrfTokenManager,
98
        SettingsManager $settingsManager,
99
        UserPasswordHasherInterface $passwordHasher
100
    ): Response {
101
        /** @var User $user */
102
        $user = $this->getUser();
103
104
        // Ensure user is authenticated and has proper interface
105
        if (!\is_object($user) || !$user instanceof UserInterface) {
106
            throw $this->createAccessDeniedException('This user does not have access to this section');
107
        }
108
109
        // Build the form and inject user-related options
110
        $form = $this->createForm(ChangePasswordType::class, [
111
            'enable2FA' => $user->getMfaEnabled(),
112
        ], [
113
            'user' => $user,
114
            'portal_name' => $settingsManager->getSetting('platform.institution'),
115
            'password_hasher' => $passwordHasher,
116
        ]);
117
118
        $form->handleRequest($request);
119
        $session = $request->getSession();
120
        $qrCodeBase64 = null;
121
        $showQRCode = false;
122
123
        // Generate TOTP secret and QR code for 2FA activation
124
        if ($form->get('enable2FA')->getData() && !$user->getMfaSecret()) {
125
            if (!$session->has('temporary_mfa_secret')) {
126
                $totp = TOTP::create();
127
                $secret = $totp->getSecret();
128
                $session->set('temporary_mfa_secret', $secret);
129
            } else {
130
                $secret = $session->get('temporary_mfa_secret');
131
            }
132
133
            $totp = TOTP::create($secret);
134
            $portalName = $settingsManager->getSetting('platform.institution');
135
            $totp->setLabel($portalName . ' - ' . $user->getEmail());
136
137
            // Build QR code image
138
            $qrCodeResult = Builder::create()
139
                ->writer(new PngWriter())
140
                ->data($totp->getProvisioningUri())
141
                ->encoding(new Encoding('UTF-8'))
142
                ->errorCorrectionLevel(new ErrorCorrectionLevelHigh())
143
                ->size(300)
144
                ->margin(10)
145
                ->build();
146
147
            $qrCodeBase64 = base64_encode($qrCodeResult->getString());
148
            $showQRCode = true;
149
        }
150
151
        // Handle form submission
152
        if ($form->isSubmitted() && $form->isValid()) {
153
            $newPassword = $form->get('newPassword')->getData();
154
            $enable2FA = $form->get('enable2FA')->getData();
155
156
            // Enable 2FA and store encrypted secret
157
            if ($enable2FA && !$user->getMfaSecret() && $session->has('temporary_mfa_secret')) {
158
                $secret = $session->get('temporary_mfa_secret');
159
                $encryptedSecret = $this->encryptTOTPSecret($secret, $_ENV['APP_SECRET']);
160
161
                $user->setMfaSecret($encryptedSecret);
162
                $user->setMfaEnabled(true);
163
                $user->setMfaService('TOTP');
164
165
                $userRepository->updateUser($user);
166
                $session->remove('temporary_mfa_secret');
167
168
                $this->addFlash('success', '2FA activated successfully.');
169
                return $this->redirectToRoute('chamilo_core_account_home');
170
            }
171
172
            // Disable 2FA if it was previously enabled
173
            if (!$enable2FA && $user->getMfaEnabled()) {
174
                $user->setMfaEnabled(false);
175
                $user->setMfaSecret(null);
176
177
                $userRepository->updateUser($user);
178
                $this->addFlash('success', '2FA disabled successfully.');
179
                return $this->redirectToRoute('chamilo_core_account_home');
180
            }
181
182
            // Update password if provided
183
            if (!empty($newPassword)) {
184
                $user->setPlainPassword($newPassword);
185
                $userRepository->updateUser($user);
186
                $this->addFlash('success', 'Password updated successfully.');
187
                return $this->redirectToRoute('chamilo_core_account_home');
188
            }
189
        }
190
191
        // Render form with optional QR code for 2FA
192
        return $this->render('@ChamiloCore/Account/change_password.html.twig', [
193
            'form' => $form->createView(),
194
            'qrCode' => $qrCodeBase64,
195
            'user' => $user,
196
            'showQRCode' => $showQRCode,
197
        ]);
198
    }
199
200
    /**
201
     * Encrypts the TOTP secret using AES-256-CBC encryption.
202
     */
203
    private function encryptTOTPSecret(string $secret, string $encryptionKey): string
204
    {
205
        $cipherMethod = 'aes-256-cbc';
206
        $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length($cipherMethod));
207
        $encryptedSecret = openssl_encrypt($secret, $cipherMethod, $encryptionKey, 0, $iv);
208
209
        return base64_encode($iv.'::'.$encryptedSecret);
210
    }
211
212
    /**
213
     * Validates the provided TOTP code for the given user.
214
     */
215
    private function isTOTPValid(User $user, string $totpCode): bool
0 ignored issues
show
Unused Code introduced by
The method isTOTPValid() 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...
216
    {
217
        $decryptedSecret = $this->decryptTOTPSecret($user->getMfaSecret(), $_ENV['APP_SECRET']);
218
        $totp = TOTP::create($decryptedSecret);
219
220
        return $totp->verify($totpCode);
221
    }
222
223
    /**
224
     * Decrypts the TOTP secret using AES-256-CBC decryption.
225
     */
226
    private function decryptTOTPSecret(string $encryptedSecret, string $encryptionKey): string
227
    {
228
        $cipherMethod = 'aes-256-cbc';
229
        list($iv, $encryptedData) = explode('::', base64_decode($encryptedSecret), 2);
230
231
        return openssl_decrypt($encryptedData, $cipherMethod, $encryptionKey, 0, $iv);
232
    }
233
234
    /**
235
     * Validate the password against the same requirements as the client-side validation.
236
     */
237
    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...
238
    {
239
        $errors = [];
240
        $minRequirements = Security::getPasswordRequirements()['min'];
241
242
        if (\strlen($password) < $minRequirements['length']) {
243
            $errors[] = $this->translator->trans('Password must be at least %length% characters long.', ['%length%' => $minRequirements['length']]);
244
        }
245
        if ($minRequirements['lowercase'] > 0 && !preg_match('/[a-z]/', $password)) {
246
            $errors[] = $this->translator->trans('Password must contain at least %count% lowercase characters.', ['%count%' => $minRequirements['lowercase']]);
247
        }
248
        if ($minRequirements['uppercase'] > 0 && !preg_match('/[A-Z]/', $password)) {
249
            $errors[] = $this->translator->trans('Password must contain at least %count% uppercase characters.', ['%count%' => $minRequirements['uppercase']]);
250
        }
251
        if ($minRequirements['numeric'] > 0 && !preg_match('/[0-9]/', $password)) {
252
            $errors[] = $this->translator->trans('Password must contain at least %count% numerical (0-9) characters.', ['%count%' => $minRequirements['numeric']]);
253
        }
254
        if ($minRequirements['specials'] > 0 && !preg_match('/[\W]/', $password)) {
255
            $errors[] = $this->translator->trans('Password must contain at least %count% special characters.', ['%count%' => $minRequirements['specials']]);
256
        }
257
258
        return $errors;
259
    }
260
}
261