Passed
Pull Request — master (#5836)
by
unknown
07:43
created

AccountController   A

Complexity

Total Complexity 36

Size/Duplication

Total Lines 196
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 115
dl 0
loc 196
rs 9.52
c 0
b 0
f 0
wmc 36

6 Methods

Rating   Name   Duplication   Size   Complexity  
A decryptTOTPSecret() 0 6 1
B validatePassword() 0 22 10
B edit() 0 36 6
C changePassword() 0 96 17
A __construct() 0 4 1
A encryptTOTPSecret() 0 7 1
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
            $illustration = $form['illustration']->getData();
62
            if ($illustration) {
63
                $illustrationRepo->deleteIllustration($user);
64
                $illustrationRepo->addIllustration($user, $user, $illustration);
65
            }
66
67
            $showTermsIfProfileCompleted = ('true' === $settingsManager->getSetting('show_terms_if_profile_completed'));
68
            $user->setProfileCompleted($showTermsIfProfileCompleted);
69
70
            $userRepository->updateUser($user);
71
            $this->addFlash('success', $this->trans('Updated'));
72
            $url = $this->generateUrl('chamilo_core_account_home');
73
74
            $request->getSession()->set('_locale_user', $user->getLocale());
75
76
            return new RedirectResponse($url);
77
        }
78
79
        return $this->render('@ChamiloCore/Account/edit.html.twig', [
80
            'form' => $form,
81
            'user' => $user,
82
        ]);
83
    }
84
85
    #[Route('/change-password', name: 'chamilo_core_account_change_password', methods: ['GET', 'POST'])]
86
    public function changePassword(Request $request, UserRepository $userRepository, CsrfTokenManagerInterface $csrfTokenManager): Response
87
    {
88
        /* @var User $user */
89
        $user = $this->getUser();
90
91
        if (!\is_object($user) || !$user instanceof UserInterface) {
92
            throw $this->createAccessDeniedException('This user does not have access to this section');
93
        }
94
95
        $form = $this->createForm(ChangePasswordType::class, [
96
            'enable2FA' => $user->getMfaEnabled(),
97
        ]);
98
        $form->handleRequest($request);
99
100
        $qrCodeBase64 = null;
101
        if ($user->getMfaEnabled() && $user->getMfaService() === 'TOTP' && $user->getMfaSecret()) {
102
            $decryptedSecret = $this->decryptTOTPSecret($user->getMfaSecret(), $_ENV['APP_SECRET']);
103
            $totp = TOTP::create($decryptedSecret);
104
            $totp->setLabel($user->getEmail());
105
106
            $qrCodeResult = Builder::create()
107
                ->writer(new PngWriter())
108
                ->data($totp->getProvisioningUri())
109
                ->encoding(new Encoding('UTF-8'))
110
                ->errorCorrectionLevel(new ErrorCorrectionLevelHigh())
111
                ->size(300)
112
                ->margin(10)
113
                ->build();
114
115
            $qrCodeBase64 = base64_encode($qrCodeResult->getString());
116
        }
117
118
        if ($form->isSubmitted() && $form->isValid()) {
119
            $submittedToken = $request->request->get('_token');
120
121
            if (!$csrfTokenManager->isTokenValid(new CsrfToken('change_password', $submittedToken))) {
122
                $form->addError(new FormError($this->translator->trans('CSRF token is invalid. Please try again.')));
123
            } else {
124
                $currentPassword = $form->get('currentPassword')->getData();
125
                $newPassword = $form->get('newPassword')->getData();
126
                $confirmPassword = $form->get('confirmPassword')->getData();
127
                $enable2FA = $form->get('enable2FA')->getData();
128
129
                if ($enable2FA && !$user->getMfaSecret()) {
130
                    $totp = TOTP::create();
131
                    $totp->setLabel($user->getEmail());
132
                    $encryptedSecret = $this->encryptTOTPSecret($totp->getSecret(), $_ENV['APP_SECRET']);
133
                    $user->setMfaSecret($encryptedSecret);
134
                    $user->setMfaEnabled(true);
135
                    $user->setMfaService('TOTP');
136
                    $userRepository->updateUser($user);
137
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
149
                    return $this->render('@ChamiloCore/Account/change_password.html.twig', [
150
                        'form' => $form->createView(),
151
                        'qrCode' => $qrCodeBase64,
152
                        'user' => $user
153
                    ]);
154
                } elseif (!$enable2FA) {
155
                    $user->setMfaEnabled(false);
156
                    $user->setMfaSecret(null);
157
                    $userRepository->updateUser($user);
158
                    $this->addFlash('success', '2FA disabled successfully.');
159
                }
160
161
                if ($newPassword || $confirmPassword || $currentPassword) {
162
                    if (!$userRepository->isPasswordValid($user, $currentPassword)) {
163
                        $form->get('currentPassword')->addError(new FormError($this->translator->trans('Current password is incorrect.')));
164
                    } elseif ($newPassword !== $confirmPassword) {
165
                        $form->get('confirmPassword')->addError(new FormError($this->translator->trans('Passwords do not match.')));
166
                    } else {
167
                        $user->setPlainPassword($newPassword);
168
                        $userRepository->updateUser($user);
169
                        $this->addFlash('success', 'Password updated successfully.');
170
                    }
171
                }
172
173
                return $this->redirectToRoute('chamilo_core_account_home');
174
            }
175
        }
176
177
        return $this->render('@ChamiloCore/Account/change_password.html.twig', [
178
            'form' => $form->createView(),
179
            'qrCode' => $qrCodeBase64,
180
            'user' => $user
181
        ]);
182
    }
183
184
    /**
185
     * Encrypts the TOTP secret using AES-256-CBC encryption.
186
     */
187
    private function encryptTOTPSecret(string $secret, string $encryptionKey): string
188
    {
189
        $cipherMethod = 'aes-256-cbc';
190
        $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length($cipherMethod));
191
        $encryptedSecret = openssl_encrypt($secret, $cipherMethod, $encryptionKey, 0, $iv);
192
193
        return base64_encode($iv . '::' . $encryptedSecret);
194
    }
195
196
    /**
197
     * Decrypts the TOTP secret using AES-256-CBC decryption.
198
     */
199
    private function decryptTOTPSecret(string $encryptedSecret, string $encryptionKey): string
200
    {
201
        $cipherMethod = 'aes-256-cbc';
202
        list($iv, $encryptedData) = explode('::', base64_decode($encryptedSecret), 2);
203
204
        return openssl_decrypt($encryptedData, $cipherMethod, $encryptionKey, 0, $iv);
205
    }
206
207
    /**
208
     * Validate the password against the same requirements as the client-side validation.
209
     */
210
    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...
211
    {
212
        $errors = [];
213
        $minRequirements = Security::getPasswordRequirements()['min'];
214
215
        if (\strlen($password) < $minRequirements['length']) {
216
            $errors[] = $this->translator->trans('Password must be at least %length% characters long.', ['%length%' => $minRequirements['length']]);
217
        }
218
        if ($minRequirements['lowercase'] > 0 && !preg_match('/[a-z]/', $password)) {
219
            $errors[] = $this->translator->trans('Password must contain at least %count% lowercase characters.', ['%count%' => $minRequirements['lowercase']]);
220
        }
221
        if ($minRequirements['uppercase'] > 0 && !preg_match('/[A-Z]/', $password)) {
222
            $errors[] = $this->translator->trans('Password must contain at least %count% uppercase characters.', ['%count%' => $minRequirements['uppercase']]);
223
        }
224
        if ($minRequirements['numeric'] > 0 && !preg_match('/[0-9]/', $password)) {
225
            $errors[] = $this->translator->trans('Password must contain at least %count% numerical (0-9) characters.', ['%count%' => $minRequirements['numeric']]);
226
        }
227
        if ($minRequirements['specials'] > 0 && !preg_match('/[\W]/', $password)) {
228
            $errors[] = $this->translator->trans('Password must contain at least %count% special characters.', ['%count%' => $minRequirements['specials']]);
229
        }
230
231
        return $errors;
232
    }
233
}
234