Passed
Pull Request — master (#6675)
by
unknown
08:28
created

AnonymousUserSubscriber::onKernelRequest()   D

Complexity

Conditions 20
Paths 15

Size

Total Lines 68
Code Lines 35

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 20
eloc 35
c 1
b 0
f 0
nc 15
nop 1
dl 0
loc 68
rs 4.1666

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
/* For licensing terms, see /license.txt */
4
5
declare(strict_types=1);
6
7
namespace Chamilo\CoreBundle\EventSubscriber;
8
9
use Chamilo\CoreBundle\Entity\Course;
10
use Chamilo\CoreBundle\Entity\TrackELogin;
11
use Chamilo\CoreBundle\Entity\User;
12
use Chamilo\CoreBundle\Settings\SettingsManager;
13
use DateTime;
14
use Doctrine\ORM\EntityManagerInterface;
15
use Symfony\Bundle\SecurityBundle\Security;
16
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
17
use Symfony\Component\HttpFoundation\Request;
18
use Symfony\Component\HttpKernel\Event\RequestEvent;
19
use Symfony\Component\HttpKernel\KernelEvents;
20
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
21
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
22
23
/**
24
 * Auto-login as an anonymous User entity ONLY within a public course context.
25
 * Keeps the anonymous session alive while the user stays around the course
26
 * (including auxiliary/XHR calls that may not carry cid), and clears it
27
 * when navigating away (top-level document navigation),
28
 * except for whitelisted paths still considered within the course context.
29
 */
30
class AnonymousUserSubscriber implements EventSubscriberInterface
31
{
32
    private const FIREWALL_NAME       = 'main';
33
    private const MAX_ANONYMOUS_USERS = 5;
34
35
    // Session flags for the “active public course” context
36
    private const S_ACTIVE_CID        = '_active_public_cid';
37
    private const S_ACTIVE_PUBLIC     = '_active_public_flag';
38
    private const S_ACTIVE_EXPIRES_AT = '_active_public_expires_at';
39
    private const S_SECURITY_TOKEN    = '_security_'.self::FIREWALL_NAME;
40
41
    // TTL (in seconds) for the “public course anonymous session” window
42
    private const ACTIVE_TTL_SECONDS  = 600; // 10 minutes
43
44
    /**
45
     * Whitelist: only preserve the anonymous context on the contact pages.
46
     * NOTE: We intentionally avoid whitelisting all LP paths here to keep scope tight.
47
     */
48
    private const ANON_WHITELIST_PREFIXES = [
49
        '/contact',
50
        '/main/lp/contact',
51
    ];
52
53
    public function __construct(
54
        private readonly Security $security,
55
        private readonly EntityManagerInterface $em,
56
        private readonly SettingsManager $settings,
57
        private readonly TokenStorageInterface $tokenStorage,
58
    ) {}
59
60
    public static function getSubscribedEvents(): array
61
    {
62
        return [ KernelEvents::REQUEST => 'onKernelRequest' ];
63
    }
64
65
    public function onKernelRequest(RequestEvent $event): void
66
    {
67
        if (!$event->isMainRequest()) {
68
            return;
69
        }
70
71
        $request     = $event->getRequest();
72
        $hasSession  = $request->hasSession();
73
        $currentUser = $this->security->getUser();
74
75
        // In a public course scope?
76
        $cid = $this->extractCid($request);
77
        if ($cid > 0 && $this->isCoursePublic($cid)) {
78
            // Renew active context (TTL)
79
            if ($hasSession) {
80
                $this->rememberActivePublicCid($request, (int) $cid);
81
            }
82
83
            // Real (non-anonymous) user → nothing to do
84
            if ($currentUser instanceof User && $currentUser->getStatus() !== User::ANONYMOUS) {
85
                return;
86
            }
87
88
            // Already an anonymous entity user → nothing to do
89
            if ($currentUser instanceof User && $currentUser->getStatus() === User::ANONYMOUS) {
90
                return;
91
            }
92
93
            // Login as anonymous entity user
94
            $this->loginAnonymousEntity($request);
95
            return;
96
        }
97
98
        // Not in a public course (or no cid on this request)
99
        if (!$hasSession) {
100
            return;
101
        }
102
103
        $session   = $request->getSession();
104
        $activeCid = (int) ($session->get(self::S_ACTIVE_CID, 0));
105
        $isActive  = (bool) ($session->get(self::S_ACTIVE_PUBLIC, false));
106
        $expiresAt = (int) ($session->get(self::S_ACTIVE_EXPIRES_AT, 0));
107
        $now       = time();
108
109
        // There is an active context and it has not expired
110
        if ($activeCid > 0 && $isActive && $expiresAt > $now) {
111
            if ($this->isTopLevelNavigation($request)) {
112
                // Top-level navigation: keep anonymous if whitelisted, otherwise clear
113
                if ($this->isWhitelistedPath($request)) {
114
                    $this->rememberActivePublicCid($request, $activeCid);
115
                    return;
116
                }
117
                $this->clearAnon($request);
118
                return;
119
            }
120
121
            // Not a top-level navigation (XHR/assets).
122
            // If it's a whitelisted path, renew TTL to avoid accidental expiration.
123
            if ($this->isWhitelistedPath($request)) {
124
                $this->rememberActivePublicCid($request, $activeCid);
125
            }
126
            return;
127
        }
128
129
        // No active context (or expired): for an ANONYMOUS user, clear only on top-level navigation outside whitelist
130
        if ($currentUser instanceof User && $currentUser->getStatus() === User::ANONYMOUS) {
131
            if ($this->isTopLevelNavigation($request) && !$this->isWhitelistedPath($request)) {
132
                $this->clearAnon($request);
133
            }
134
        }
135
    }
136
137
    /**
138
     * Extract course id from:
139
     *  - Query ?cid=...
140
     *  - Path /course/{id}/...
141
     *  - Path /api/courses/{id}
142
     */
143
    private function extractCid(Request $request): int
144
    {
145
        $cid = $request->query->get('cid');
146
        if (is_numeric($cid) && (int) $cid > 0) {
147
            return (int) $cid;
148
        }
149
150
        $path = $request->getPathInfo();
151
152
        if (preg_match('#^/course/(\d+)(?:/|$)#', $path, $m)) {
153
            return (int) $m[1];
154
        }
155
156
        if (preg_match('#^/api/courses/(\d+)(?:/|$)#', $path, $m)) {
157
            return (int) $m[1];
158
        }
159
160
        return 0;
161
    }
162
163
    private function isCoursePublic(int $cid): bool
164
    {
165
        /** @var Course|null $course */
166
        $course = $this->em->getRepository(Course::class)->find($cid);
167
        return $course?->isPublic() ?? false;
168
    }
169
170
    /** Store/renew the active public course context in session. */
171
    private function rememberActivePublicCid(Request $request, int $cid): void
172
    {
173
        $session = $request->getSession();
174
        $session->set(self::S_ACTIVE_CID, $cid);
175
        $session->set(self::S_ACTIVE_PUBLIC, true);
176
        $session->set(self::S_ACTIVE_EXPIRES_AT, time() + self::ACTIVE_TTL_SECONDS);
177
    }
178
179
    /** Log in as an anonymous entity User (create/reuse and set a UsernamePasswordToken). */
180
    private function loginAnonymousEntity(Request $request): void
181
    {
182
        $userIp = $request->getClientIp() ?: '127.0.0.1';
183
        $anonId = $this->getOrCreateAnonymousUserId($userIp);
184
        if (null === $anonId) {
185
            return;
186
        }
187
188
        // Register login if it doesn't exist yet
189
        $trackRepo = $this->em->getRepository(TrackELogin::class);
190
        if (!$trackRepo->findOneBy(['userIp' => $userIp, 'user' => $anonId])) {
191
            $trackLogin = (new TrackELogin())
192
                ->setUserIp($userIp)
193
                ->setLoginDate(new DateTime())
194
                ->setUser($this->em->getReference(User::class, $anonId));
195
            $this->em->persist($trackLogin);
196
            $this->em->flush();
197
        }
198
199
        // Set token
200
        $userRepo = $this->em->getRepository(User::class);
201
        $user     = $userRepo->find($anonId);
202
        if (!$user) {
203
            return;
204
        }
205
206
        if ($request->hasSession()) {
207
            $request->getSession()->set('_user', [
208
                'user_id'         => $user->getId(),
209
                'username'        => $user->getUsername(),
210
                'firstname'       => $user->getFirstname(),
211
                'lastname'        => $user->getLastname(),
212
                'firstName'       => $user->getFirstname(),
213
                'lastName'        => $user->getLastname(),
214
                'email'           => $user->getEmail(),
215
                'official_code'   => $user->getOfficialCode(),
216
                'picture_uri'     => $user->getPictureUri(),
217
                'status'          => $user->getStatus(),
218
                'active'          => $user->isActive(),
219
                'theme'           => $user->getTheme(),
220
                'language'        => $user->getLocale(),
221
                'created_at'      => $user->getCreatedAt()->format('Y-m-d H:i:s'),
222
                'expiration_date' => $user->getExpirationDate() ? $user->getExpirationDate()->format('Y-m-d H:i:s') : null,
223
                'last_login'      => $user->getLastLogin() ? $user->getLastLogin()->format('Y-m-d H:i:s') : null,
224
                'is_anonymous'    => true,
225
            ]);
226
        }
227
228
        $roles = $user->getRoles();
229
        $this->tokenStorage->setToken(new UsernamePasswordToken($user, self::FIREWALL_NAME, $roles));
230
    }
231
232
    /** Clear token and session flags. */
233
    private function clearAnon(Request $request): void
234
    {
235
        $this->tokenStorage->setToken(null);
236
237
        if ($request->hasSession()) {
238
            $session = $request->getSession();
239
            $session->remove('_user');
240
            $session->remove(self::S_SECURITY_TOKEN);
241
            $session->remove(self::S_ACTIVE_CID);
242
            $session->remove(self::S_ACTIVE_PUBLIC);
243
            $session->remove(self::S_ACTIVE_EXPIRES_AT);
244
        }
245
    }
246
247
    /**
248
     * Consider it “top-level document navigation” if:
249
     *  - It is NOT an XHR (no `X-Requested-With: XMLHttpRequest`)
250
     *  - and browser sends `Sec-Fetch-Mode: navigate` and `Sec-Fetch-Dest: document`
251
     *  - or the Accept header includes `text/html`
252
     */
253
    private function isTopLevelNavigation(Request $request): bool
254
    {
255
        if ($request->isXmlHttpRequest()) {
256
            return false;
257
        }
258
259
        $mode = (string) $request->headers->get('Sec-Fetch-Mode', '');
260
        $dest = (string) $request->headers->get('Sec-Fetch-Dest', '');
261
        if ($mode === 'navigate' && $dest === 'document') {
262
            return true;
263
        }
264
265
        $accept = (string) $request->headers->get('Accept', '');
266
        return str_contains($accept, 'text/html');
267
    }
268
269
    /**
270
     * Only contact-related paths preserve the anonymous context (tight scope).
271
     * Examples matched:
272
     *  - /contact
273
     *  - /contact/
274
     *  - /main/lp/contact
275
     *  - /main/lp/contact/...
276
     */
277
    private function isWhitelistedPath(Request $request): bool
278
    {
279
        $path = $request->getPathInfo() ?? '/';
280
        foreach (self::ANON_WHITELIST_PREFIXES as $prefix) {
281
            if ($path === $prefix || str_starts_with($path, $prefix.'/')) {
282
                return true;
283
            }
284
        }
285
        return false;
286
    }
287
288
    private function getOrCreateAnonymousUserId(string $userIp): ?int
289
    {
290
        $userRepo  = $this->em->getRepository(User::class);
291
        $trackRepo = $this->em->getRepository(TrackELogin::class);
292
        $autoProv  = 'true' === $this->settings->getSetting('security.anonymous_autoprovisioning');
293
294
        if (!$autoProv) {
295
            $u = $userRepo->findOneBy(['status' => User::ANONYMOUS], ['createdAt' => 'ASC']);
296
            return $u ? $u->getId() : $this->createAnonymousUser()->getId();
297
        }
298
299
        $max  = (int) $this->settings->getSetting('admin.max_anonymous_users') ?: self::MAX_ANONYMOUS_USERS;
300
        $list = $userRepo->findBy(['status' => User::ANONYMOUS], ['createdAt' => 'ASC']);
301
302
        // Reuse by IP if there is a previous login record
303
        foreach ($list as $u) {
304
            if ($trackRepo->findOneBy(['userIp' => $userIp, 'user' => $u])) {
305
                return $u->getId();
306
            }
307
        }
308
309
        // Trim excess anonymous users
310
        while (\count($list) >= $max) {
311
            $oldest = array_shift($list);
312
            if ($oldest) {
313
                $this->em->remove($oldest);
314
                $this->em->flush();
315
            }
316
        }
317
318
        return $this->createAnonymousUser()->getId();
319
    }
320
321
    private function createAnonymousUser(): User
322
    {
323
        $uniqueId = uniqid('anon_');
324
        $email    = $uniqueId.'@localhost.local';
325
326
        if ('true' === $this->settings->getSetting('profile.login_is_email')) {
327
            $uniqueId = $email;
328
        }
329
330
        $anonymousUser = (new User())
331
            ->setSkipResourceNode(true)
332
            ->setLastname('Doe')
333
            ->setFirstname('Anonymous')
334
            ->setUsername('anon_'.$uniqueId)
335
            ->setStatus(User::ANONYMOUS)
336
            ->setPlainPassword('anon')
337
            ->setEmail($email)
338
            ->setOfficialCode('anonymous')
339
            ->setCreatorId(1)
340
            ->addRole('ROLE_ANONYMOUS');
341
342
        $this->em->persist($anonymousUser);
343
        $this->em->flush();
344
345
        return $anonymousUser;
346
    }
347
}
348