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

SecurityController   D

Complexity

Total Complexity 58

Size/Duplication

Total Lines 391
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 199
dl 0
loc 391
rs 4.5599
c 0
b 0
f 0
wmc 58

10 Methods

Rating   Name   Duplication   Size   Complexity  
F loginJson() 0 168 21
A isTOTPValid() 0 6 1
B calculateRedirectUrl() 0 66 10
A checkSession() 0 8 1
A decryptTOTPSecret() 0 12 2
D getRedirectAfterLoginPath() 0 49 17
A loginTokenRequest() 0 18 2
A ldapLoginCheck() 0 11 2
A loginTokenCheck() 0 5 1
A __construct() 0 13 1

How to fix   Complexity   

Complex Class

Complex classes like SecurityController 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 SecurityController, 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\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