Passed
Pull Request — master (#7339)
by
unknown
09:20
created

SecurityController::loginJson()   F

Complexity

Conditions 21
Paths 141

Size

Total Lines 168
Code Lines 102

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 21
eloc 102
nc 141
nop 3
dl 0
loc 168
rs 3.06
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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\Course;
10
use Chamilo\CoreBundle\Entity\ExtraFieldValues;
11
use Chamilo\CoreBundle\Entity\Legal;
12
use Chamilo\CoreBundle\Entity\User;
13
use Chamilo\CoreBundle\Entity\ValidationToken;
14
use Chamilo\CoreBundle\Helpers\AccessUrlHelper;
15
use Chamilo\CoreBundle\Helpers\AuthenticationConfigHelper;
16
use Chamilo\CoreBundle\Helpers\IsAllowedToEditHelper;
17
use Chamilo\CoreBundle\Helpers\UserHelper;
18
use Chamilo\CoreBundle\Helpers\ValidationTokenHelper;
19
use Chamilo\CoreBundle\Repository\Node\AccessUrlRepository;
20
use Chamilo\CoreBundle\Repository\Node\CourseRepository;
21
use Chamilo\CoreBundle\Repository\TrackELoginRecordRepository;
22
use Chamilo\CoreBundle\Repository\ValidationTokenRepository;
23
use Chamilo\CoreBundle\Security\Authenticator\Ldap\LdapAuthenticator;
24
use Chamilo\CoreBundle\Security\Authenticator\LoginTokenAuthenticator;
25
use Chamilo\CoreBundle\Settings\SettingsManager;
26
use DateTime;
27
use DateTimeImmutable;
28
use Doctrine\ORM\EntityManagerInterface;
29
use Exception;
30
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
31
use OTPHP\TOTP;
32
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
33
use Symfony\Bundle\SecurityBundle\Security;
34
use Symfony\Component\HttpFoundation\Cookie;
35
use Symfony\Component\HttpFoundation\JsonResponse;
36
use Symfony\Component\HttpFoundation\Request;
37
use Symfony\Component\HttpFoundation\Response;
38
use Symfony\Component\Routing\Attribute\Route;
39
use Symfony\Component\Routing\RouterInterface;
40
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
41
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
42
use Symfony\Component\Security\Http\Attribute\IsGranted;
43
use Symfony\Component\Serializer\SerializerInterface;
44
use Symfony\Contracts\Translation\TranslatorInterface;
45
46
class SecurityController extends AbstractController
47
{
48
    public function __construct(
49
        private SerializerInterface $serializer,
50
        private TrackELoginRecordRepository $trackELoginRecordRepository,
51
        private EntityManagerInterface $entityManager,
52
        private SettingsManager $settingsManager,
53
        private TokenStorageInterface $tokenStorage,
54
        private AuthorizationCheckerInterface $authorizationChecker,
55
        private readonly UserHelper $userHelper,
56
        private readonly RouterInterface $router,
57
        private readonly AccessUrlHelper $accessUrlHelper,
58
        private readonly IsAllowedToEditHelper $isAllowedToEditHelper,
59
        private readonly AccessUrlRepository $accessUrlRepo,
60
    ) {}
61
62
    #[Route('/login_json', name: 'login_json', methods: ['POST'])]
63
    public function loginJson(
64
        Request $request,
65
        TokenStorageInterface $tokenStorage,
66
        TranslatorInterface $translator,
67
    ): Response {
68
        if (!$this->isGranted('IS_AUTHENTICATED_FULLY')) {
69
            throw $this->createAccessDeniedException($translator->trans('Invalid login request: check that the Content-Type header is <em>application/json</em>.'));
70
        }
71
72
        $dataRequest = json_decode($request->getContent(), true);
73
        if (!\is_array($dataRequest)) {
74
            $dataRequest = [];
75
        }
76
77
        $rememberRequested = (bool) ($dataRequest['_remember_me'] ?? false);
78
79
        $user = $this->userHelper->getCurrent();
80
81
        if (User::ACTIVE !== $user->getActive()) {
82
            if (User::INACTIVE === $user->getActive()) {
83
                $message = $translator->trans('Your account has not been activated.');
84
            } else {
85
                $message = $translator->trans('Invalid credentials. Please try again or contact support if you continue to experience issues.');
86
            }
87
88
            $tokenStorage->setToken(null);
89
            $request->getSession()->invalidate();
90
91
            return $this->createAccessDeniedException($message);
92
        }
93
94
        if ($user->getMfaEnabled()) {
95
            $totpCode = $dataRequest['totp'] ?? null;
96
97
            if (null === $totpCode) {
98
                $tokenStorage->setToken(null);
99
                $request->getSession()->invalidate();
100
101
                return $this->json(['requires2FA' => true], 200);
102
            }
103
104
            if (!$this->isTOTPValid($user, $totpCode)) {
105
                $tokenStorage->setToken(null);
106
                $request->getSession()->invalidate();
107
108
                return $this->json(['error' => 'Invalid 2FA code.'], 401);
109
            }
110
        }
111
112
        if (null !== $user->getExpirationDate() && $user->getExpirationDate() <= new DateTime()) {
113
            $message = $translator->trans('Your account has expired.');
114
115
            $tokenStorage->setToken(null);
116
            $request->getSession()->invalidate();
117
118
            return $this->createAccessDeniedException($message);
119
        }
120
121
        $extraFieldValuesRepository = $this->entityManager->getRepository(ExtraFieldValues::class);
122
        $legalTermsRepo = $this->entityManager->getRepository(Legal::class);
123
        if (
124
            $user->isStudent()
125
            && 'true' === $this->settingsManager->getSetting('allow_terms_conditions', true)
126
            && 'login' === $this->settingsManager->getSetting('workflows.load_term_conditions_section', true)
127
        ) {
128
            $termAndConditionStatus = false;
129
            $extraValue = $extraFieldValuesRepository->findLegalAcceptByItemId($user->getId());
130
            if (!empty($extraValue['value'])) {
131
                $result = $extraValue['value'];
132
                $userConditions = explode(':', $result);
133
                $version = $userConditions[0];
134
                $langId = (int) $userConditions[1];
135
                $realVersion = $legalTermsRepo->getLastVersion($langId);
136
                $termAndConditionStatus = ($version >= $realVersion);
137
            }
138
139
            if (false === $termAndConditionStatus) {
140
                $tempTermAndCondition = ['user_id' => $user->getId()];
141
                $this->tokenStorage->setToken(null);
142
                $request->getSession()->invalidate();
143
                $request->getSession()->start();
144
                $request->getSession()->set('term_and_condition', $tempTermAndCondition);
145
146
                $afterLogin = $this->getRedirectAfterLoginPath($user);
147
148
                return $this->json([
149
                    'load_terms' => true,
150
                    'redirect' => '/main/auth/tc.php?return='.urlencode($afterLogin),
151
                ]);
152
            }
153
            $request->getSession()->remove('term_and_condition');
154
        }
155
156
        $redirectUrl = $this->calculateRedirectUrl(
157
            $user,
158
            $this->entityManager->getRepository(Course::class),
159
        );
160
161
        if (null !== $redirectUrl) {
162
            return $this->json([
163
                'redirect' => $redirectUrl,
164
            ]);
165
        }
166
167
        // Password rotation check
168
        $days = (int) $this->settingsManager->getSetting('security.password_rotation_days', true);
169
        if ($days > 0) {
170
            $lastUpdate = $user->getPasswordUpdatedAt() ?? $user->getCreatedAt();
171
            $diffDays = (new DateTimeImmutable())->diff($lastUpdate)->days;
0 ignored issues
show
Documentation Bug introduced by
It seems like new DateTimeImmutable()->diff($lastUpdate)->days can also be of type boolean. However, the property $days is declared as type false|integer. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
172
173
            if ($diffDays > $days) {
174
                // Clean token & session
175
                $tokenStorage->setToken(null);
176
                $request->getSession()->invalidate();
177
178
                return $this->json([
179
                    'rotate_password' => true,
180
                    'redirect' => '/account/change-password?rotate=1&userId='.$user->getId(),
181
                ]);
182
            }
183
        }
184
185
        $data = null;
186
        if ($user) {
187
            $data = $this->serializer->serialize($user, 'jsonld', ['groups' => ['user_json:read']]);
188
        }
189
190
        $response = new JsonResponse($data, Response::HTTP_OK, [], true);
191
192
        // Remember Me: only on HTTPS.
193
        if ($rememberRequested && $request->isSecure()) {
194
            $ttlSeconds = 1209600; // 14 days
195
196
            // Opportunistic cleanup of expired remember-me tokens.
197
            /** @var ValidationTokenRepository $validationTokenRepo */
198
            $validationTokenRepo = $this->entityManager->getRepository(ValidationToken::class);
199
            $validationTokenRepo->deleteExpiredRememberMeTokens((new DateTimeImmutable())->modify('-'.$ttlSeconds.' seconds'));
200
201
            $rawToken = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
202
            $hash = hash('sha256', $rawToken);
203
204
            $tokenEntity = new ValidationToken(ValidationTokenHelper::TYPE_REMEMBER_ME, (int) $user->getId(), $hash);
205
            $validationTokenRepo->save($tokenEntity, true);
206
207
            $secret = (string) $this->getParameter('kernel.secret');
208
            $sigRaw = hash_hmac('sha256', (string) $user->getId().'|'.$rawToken, $secret, true);
209
            $sig = rtrim(strtr(base64_encode($sigRaw), '+/', '-_'), '=');
210
211
            $cookieValue = $user->getId().':'.$rawToken.':'.$sig;
212
            $expiresAt = (new DateTimeImmutable())->modify('+'.$ttlSeconds.' seconds');
213
214
            $cookie = new Cookie(
215
                'ch_remember_me',
216
                $cookieValue,
217
                $expiresAt->getTimestamp(),
218
                '/',
219
                null,
220
                true,  // Secure
221
                true,  // HttpOnly
222
                false,
223
                Cookie::SAMESITE_STRICT
224
            );
225
226
            $response->headers->setCookie($cookie);
227
        }
228
229
        return $response;
230
    }
231
232
    #[IsGranted('IS_AUTHENTICATED_FULLY')]
233
    #[Route('/check-session', name: 'check_session', methods: ['GET'])]
234
    public function checkSession(): JsonResponse
235
    {
236
        $user = $this->userHelper->getCurrent();
237
        $data = $this->serializer->serialize($user, 'jsonld', ['groups' => ['user_json:read']]);
238
239
        return new JsonResponse(['isAuthenticated' => true, 'user' => json_decode($data)], Response::HTTP_OK);
240
    }
241
242
    #[Route('/login/token/request', name: 'login_token_request', methods: ['GET'])]
243
    public function loginTokenRequest(
244
        JWTTokenManagerInterface $jwtManager,
245
        Security $security,
246
    ): JsonResponse {
247
        $user = $this->userHelper->getCurrent();
248
249
        if (!$user) {
250
            throw $this->createAccessDeniedException();
251
        }
252
253
        $token = $jwtManager->create($user);
254
255
        // Logout the current user in the login-only Access URL
256
        $security->logout(false);
257
258
        return new JsonResponse([
259
            'token' => $token,
260
        ]);
261
    }
262
263
    /**
264
     * @see LoginTokenAuthenticator
265
     */
266
    #[Route('/login/token/check', name: 'login_token_check', methods: ['POST'])]
267
    public function loginTokenCheck(): Response
268
    {
269
        // this response was managed in LoginTokenAuthenticator class
270
        return new Response(null, Response::HTTP_NO_CONTENT);
271
    }
272
273
    /**
274
     * @see LdapAuthenticator
275
     */
276
    #[Route('/login/ldap/check', name: 'login_ldap_check', methods: ['POST'], format: 'json')]
277
    public function ldapLoginCheck(AuthenticationConfigHelper $authConfigHelper): Response
278
    {
279
        $ldapConfig = $authConfigHelper->getLdapConfig();
280
281
        if (!$ldapConfig['enabled']) {
282
            throw $this->createAccessDeniedException();
283
        }
284
285
        // this response was managed in LdapAuthenticator class
286
        return new Response(null, Response::HTTP_NO_CONTENT);
287
    }
288
289
    /**
290
     * Validates the provided TOTP code for the given user.
291
     *
292
     * @param mixed $user
293
     */
294
    private function isTOTPValid($user, string $totpCode): bool
295
    {
296
        $decryptedSecret = $this->decryptTOTPSecret($user->getMfaSecret(), $_ENV['APP_SECRET']);
297
        $totp = TOTP::create($decryptedSecret);
298
299
        return $totp->verify($totpCode);
300
    }
301
302
    /**
303
     * Decrypts the stored TOTP secret.
304
     */
305
    private function decryptTOTPSecret(string $encryptedSecret, string $encryptionKey): string
306
    {
307
        $cipherMethod = 'aes-256-cbc';
308
309
        try {
310
            list($iv, $encryptedData) = explode('::', base64_decode($encryptedSecret), 2);
311
312
            return openssl_decrypt($encryptedData, $cipherMethod, $encryptionKey, 0, $iv);
313
        } catch (Exception $e) {
314
            error_log('Exception caught during decryption: '.$e->getMessage());
315
316
            return '';
317
        }
318
    }
319
320
    private function calculateRedirectUrl(
321
        User $user,
322
        CourseRepository $courseRepo,
323
    ): ?string {
324
        /* Possible values: index.php, user_portal.php, main/auth/courses.php */
325
        $pageAfterLogin = $this->settingsManager->getSetting('registration.redirect_after_login');
326
327
        $url = null;
328
329
        if ($user->isStudent() && !empty($pageAfterLogin)) {
330
            $url = match ($pageAfterLogin) {
331
                'index.php' => null,
332
                'user_portal.php' => $this->router->generate('courses', [], RouterInterface::ABSOLUTE_URL),
333
                'main/auth/courses.php' => $this->router->generate('catalogue', ['slug' => 'courses'], RouterInterface::ABSOLUTE_URL),
334
                default => null,
335
            };
336
        }
337
338
        if ('true' !== $this->settingsManager->getSetting('workflows.go_to_course_after_login')) {
339
            return $url;
340
        }
341
342
        $personalCourseList = $courseRepo->getPersonalSessionCourses(
343
            $user,
344
            $this->accessUrlHelper->getCurrent(),
345
            $this->isAllowedToEditHelper->canCreateCourse()
346
        );
347
348
        $mySessionList = [];
349
        $countOfCoursesNoSessions = 0;
350
351
        foreach ($personalCourseList as $course) {
352
            if (!empty($course['sid'])) {
353
                $mySessionList[$course['sid']] = true;
354
            } else {
355
                $countOfCoursesNoSessions++;
356
            }
357
        }
358
359
        $countOfSessions = \count($mySessionList);
360
361
        if (1 === $countOfSessions && 0 === $countOfCoursesNoSessions) {
362
            $key = array_keys($personalCourseList);
363
364
            return $this->router->generate(
365
                'chamilo_core_course_home',
366
                [
367
                    'cid' => $personalCourseList[$key[0]]['cid'],
368
                    'sid' => $personalCourseList[$key[0]]['sid'] ?? 0,
369
                ]
370
            );
371
        }
372
373
        if (0 === $countOfSessions && 1 === $countOfCoursesNoSessions) {
374
            $key = array_keys($personalCourseList);
375
376
            return $this->router->generate(
377
                'chamilo_core_course_home',
378
                [
379
                    'cid' => $personalCourseList[$key[0]]['cid'],
380
                    'sid' => 0,
381
                ]
382
            );
383
        }
384
385
        return null;
386
    }
387
388
    private function getRedirectAfterLoginPath(User $user): string
389
    {
390
        $setting = $this->settingsManager->getSetting('registration.redirect_after_login');
391
392
        if (!\is_string($setting) || '' === trim($setting)) {
393
            return '/home';
394
        }
395
396
        $map = json_decode($setting, true);
397
        if (!\is_array($map)) {
398
            return '/home';
399
        }
400
401
        $roles = $user->getRoles();
402
403
        $profile = null;
404
        if (\in_array('ROLE_ADMIN', $roles, true)) {
405
            $profile = 'ADMIN';
406
        } elseif (\in_array('ROLE_SESSION_MANAGER', $roles, true)) {
407
            $profile = 'SESSIONADMIN';
408
        } elseif (\in_array('ROLE_TEACHER', $roles, true)) {
409
            $profile = 'COURSEMANAGER';
410
        } elseif (\in_array('ROLE_STUDENT_BOSS', $roles, true)) {
411
            $profile = 'STUDENT_BOSS';
412
        } elseif (\in_array('ROLE_DRH', $roles, true)) {
413
            $profile = 'DRH';
414
        } elseif (\in_array('ROLE_INVITEE', $roles, true)) {
415
            $profile = 'INVITEE';
416
        } elseif (\in_array('ROLE_STUDENT', $roles, true)) {
417
            $profile = 'STUDENT';
418
        }
419
420
        $value = $profile && \array_key_exists($profile, $map) ? (string) $map[$profile] : '';
421
        if ('' === trim($value)) {
422
            return '/home';
423
        }
424
425
        // Normalize a relative path
426
        $value = ltrim($value, '/');
427
428
        // Keep backward compatibility with old known values
429
        if ('index.php' === $value || 'user_portal.php' === $value) {
430
            return '/home';
431
        }
432
        if ('main/auth/courses.php' === $value) {
433
            return '/courses';
434
        }
435
436
        return '/'.$value;
437
    }
438
}
439