Passed
Push — master ( b3dfcb...0033b2 )
by Yannick
10:56
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 OTPHP\TOTP;
18
use Security;
19
use Symfony\Component\Form\FormError;
20
use Symfony\Component\HttpFoundation\RedirectResponse;
21
use Symfony\Component\HttpFoundation\Request;
22
use Symfony\Component\HttpFoundation\Response;
23
use Symfony\Component\Routing\Attribute\Route;
24
use Symfony\Component\Security\Core\User\UserInterface;
25
use Symfony\Component\Security\Csrf\CsrfToken;
26
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
27
use Symfony\Contracts\Translation\TranslatorInterface;
28
use Endroid\QrCode\Builder\Builder;
29
use Endroid\QrCode\Encoding\Encoding;
30
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh;
31
use Endroid\QrCode\Writer\PngWriter;
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): 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
        if ($user->getMfaEnabled() && $user->getMfaService() === 'TOTP' && $user->getMfaSecret()) {
111
            $decryptedSecret = $this->decryptTOTPSecret($user->getMfaSecret(), $_ENV['APP_SECRET']);
112
            $totp = TOTP::create($decryptedSecret);
113
            $totp->setLabel($user->getEmail());
114
115
            $qrCodeResult = Builder::create()
116
                ->writer(new PngWriter())
117
                ->data($totp->getProvisioningUri())
118
                ->encoding(new Encoding('UTF-8'))
119
                ->errorCorrectionLevel(new ErrorCorrectionLevelHigh())
120
                ->size(300)
121
                ->margin(10)
122
                ->build();
123
124
            $qrCodeBase64 = base64_encode($qrCodeResult->getString());
125
        }
126
127
        if ($form->isSubmitted() && $form->isValid()) {
128
            $submittedToken = $request->request->get('_token');
129
130
            if (!$csrfTokenManager->isTokenValid(new CsrfToken('change_password', $submittedToken))) {
131
                $form->addError(new FormError($this->translator->trans('CSRF token is invalid. Please try again.')));
132
            } else {
133
                $currentPassword = $form->get('currentPassword')->getData();
134
                $newPassword = $form->get('newPassword')->getData();
135
                $confirmPassword = $form->get('confirmPassword')->getData();
136
                $enable2FA = $form->get('enable2FA')->getData();
137
138
                if ($enable2FA && !$user->getMfaSecret()) {
139
                    $totp = TOTP::create();
140
                    $totp->setLabel($user->getEmail());
141
                    $encryptedSecret = $this->encryptTOTPSecret($totp->getSecret(), $_ENV['APP_SECRET']);
142
                    $user->setMfaSecret($encryptedSecret);
143
                    $user->setMfaEnabled(true);
144
                    $user->setMfaService('TOTP');
145
                    $userRepository->updateUser($user);
146
147
                    $qrCodeResult = Builder::create()
148
                        ->writer(new PngWriter())
149
                        ->data($totp->getProvisioningUri())
150
                        ->encoding(new Encoding('UTF-8'))
151
                        ->errorCorrectionLevel(new ErrorCorrectionLevelHigh())
152
                        ->size(300)
153
                        ->margin(10)
154
                        ->build();
155
156
                    $qrCodeBase64 = base64_encode($qrCodeResult->getString());
157
158
                    return $this->render('@ChamiloCore/Account/change_password.html.twig', [
159
                        'form' => $form->createView(),
160
                        'qrCode' => $qrCodeBase64,
161
                        'user' => $user
162
                    ]);
163
                } elseif (!$enable2FA) {
164
                    $user->setMfaEnabled(false);
165
                    $user->setMfaSecret(null);
166
                    $userRepository->updateUser($user);
167
                    $this->addFlash('success', '2FA disabled successfully.');
168
                }
169
170
                if ($newPassword || $confirmPassword || $currentPassword) {
171
                    if (!$userRepository->isPasswordValid($user, $currentPassword)) {
172
                        $form->get('currentPassword')->addError(new FormError($this->translator->trans('The current password is incorrect')));
173
                    } elseif ($newPassword !== $confirmPassword) {
174
                        $form->get('confirmPassword')->addError(new FormError($this->translator->trans('Passwords do not match')));
175
                    } else {
176
                        $user->setPlainPassword($newPassword);
177
                        $userRepository->updateUser($user);
178
                        $this->addFlash('success', 'Password updated successfully');
179
                    }
180
                }
181
182
                return $this->redirectToRoute('chamilo_core_account_home');
183
            }
184
        }
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
    /**
194
     * Encrypts the TOTP secret using AES-256-CBC encryption.
195
     */
196
    private function encryptTOTPSecret(string $secret, string $encryptionKey): string
197
    {
198
        $cipherMethod = 'aes-256-cbc';
199
        $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length($cipherMethod));
200
        $encryptedSecret = openssl_encrypt($secret, $cipherMethod, $encryptionKey, 0, $iv);
201
202
        return base64_encode($iv . '::' . $encryptedSecret);
203
    }
204
205
    /**
206
     * Decrypts the TOTP secret using AES-256-CBC decryption.
207
     */
208
    private function decryptTOTPSecret(string $encryptedSecret, string $encryptionKey): string
209
    {
210
        $cipherMethod = 'aes-256-cbc';
211
        list($iv, $encryptedData) = explode('::', base64_decode($encryptedSecret), 2);
212
213
        return openssl_decrypt($encryptedData, $cipherMethod, $encryptionKey, 0, $iv);
214
    }
215
216
    /**
217
     * Validate the password against the same requirements as the client-side validation.
218
     */
219
    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...
220
    {
221
        $errors = [];
222
        $minRequirements = Security::getPasswordRequirements()['min'];
223
224
        if (\strlen($password) < $minRequirements['length']) {
225
            $errors[] = $this->translator->trans('Password must be at least %length% characters long.', ['%length%' => $minRequirements['length']]);
226
        }
227
        if ($minRequirements['lowercase'] > 0 && !preg_match('/[a-z]/', $password)) {
228
            $errors[] = $this->translator->trans('Password must contain at least %count% lowercase characters.', ['%count%' => $minRequirements['lowercase']]);
229
        }
230
        if ($minRequirements['uppercase'] > 0 && !preg_match('/[A-Z]/', $password)) {
231
            $errors[] = $this->translator->trans('Password must contain at least %count% uppercase characters.', ['%count%' => $minRequirements['uppercase']]);
232
        }
233
        if ($minRequirements['numeric'] > 0 && !preg_match('/[0-9]/', $password)) {
234
            $errors[] = $this->translator->trans('Password must contain at least %count% numerical (0-9) characters.', ['%count%' => $minRequirements['numeric']]);
235
        }
236
        if ($minRequirements['specials'] > 0 && !preg_match('/[\W]/', $password)) {
237
            $errors[] = $this->translator->trans('Password must contain at least %count% special characters.', ['%count%' => $minRequirements['specials']]);
238
        }
239
240
        return $errors;
241
    }
242
}
243