Passed
Pull Request — master (#6630)
by
unknown
08:10
created

AnonymousUserSubscriber::rememberActivePublicCid()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
c 0
b 0
f 0
nc 1
nop 2
dl 0
loc 6
rs 10
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
 */
29
class AnonymousUserSubscriber implements EventSubscriberInterface
30
{
31
    private const FIREWALL_NAME       = 'main';
32
    private const MAX_ANONYMOUS_USERS = 5;
33
34
    // Session flags for the “active public course” context
35
    private const S_ACTIVE_CID        = '_active_public_cid';
36
    private const S_ACTIVE_PUBLIC     = '_active_public_flag';
37
    private const S_ACTIVE_EXPIRES_AT = '_active_public_expires_at';
38
    private const S_SECURITY_TOKEN    = '_security_'.self::FIREWALL_NAME;
39
40
    // TTL (in seconds) for the “public course anonymous session” window
41
    private const ACTIVE_TTL_SECONDS  = 600; // 10 minutes
42
43
    public function __construct(
44
        private readonly Security $security,
45
        private readonly EntityManagerInterface $em,
46
        private readonly SettingsManager $settings,
47
        private readonly TokenStorageInterface $tokenStorage,
48
    ) {}
49
50
    public static function getSubscribedEvents(): array
51
    {
52
        return [ KernelEvents::REQUEST => 'onKernelRequest' ];
53
    }
54
55
    public function onKernelRequest(RequestEvent $event): void
56
    {
57
        if (!$event->isMainRequest()) {
58
            return;
59
        }
60
61
        $request     = $event->getRequest();
62
        $hasSession  = $request->hasSession();
63
        $currentUser = $this->security->getUser();
64
65
        // Are we currently in a public course scope?
66
        $cid = $this->extractCid($request);
67
        if ($cid > 0 && $this->isCoursePublic($cid)) {
68
            // Refresh the “active public course” context (TTL)
69
            if ($hasSession) {
70
                $this->rememberActivePublicCid($request, $cid);
71
            }
72
73
            // If there is a real user (non-anonymous), do nothing
74
            if ($currentUser instanceof User && $currentUser->getStatus() !== User::ANONYMOUS) {
75
                return;
76
            }
77
78
            // If it's already an anonymous entity user, do nothing
79
            if ($currentUser instanceof User && $currentUser->getStatus() === User::ANONYMOUS) {
80
                return;
81
            }
82
83
            // 1.a) Log in as anonymous entity User (works with voters/Doctrine)
84
            $this->loginAnonymousEntity($request);
85
86
            return;
87
        }
88
89
        // We are not in a public course (or there is no cid on this request)
90
        if (!$hasSession) {
91
            return;
92
        }
93
94
        $session   = $request->getSession();
95
        $activeCid = (int) ($session->get(self::S_ACTIVE_CID, 0));
96
        $isActive  = (bool) ($session->get(self::S_ACTIVE_PUBLIC, false));
97
        $expiresAt = (int) ($session->get(self::S_ACTIVE_EXPIRES_AT, 0));
98
        $now       = time();
99
100
        // If there is an active public course context and it has not expired,
101
        //      DO NOT clear on XHR/assets. Only clear on top-level document navigation.
102
        if ($activeCid > 0 && $isActive && $expiresAt > $now) {
103
            if ($this->isTopLevelNavigation($request)) {
104
                // Navigated away from a course → clear the anonymous context
105
                $this->clearAnon($request);
106
            }
107
            return;
108
        }
109
110
        // If there is no active context or it expired and the user is anonymous → clear only on navigation
111
        if ($currentUser instanceof User && $currentUser->getStatus() === User::ANONYMOUS) {
112
            if ($this->isTopLevelNavigation($request)) {
113
                $this->clearAnon($request);
114
            }
115
        }
116
    }
117
118
    /**
119
     * Extract the course id from:
120
     *  - Query ?cid=...
121
     *  - Path /course/{id}/...
122
     *  - Path /api/courses/{id}
123
     */
124
    private function extractCid(Request $request): int
125
    {
126
        $cid = $request->query->get('cid');
127
        if (is_numeric($cid) && (int) $cid > 0) {
128
            return (int) $cid;
129
        }
130
131
        $path = $request->getPathInfo();
132
133
        if (preg_match('#^/course/(\d+)(?:/|$)#', $path, $m)) {
134
            return (int) $m[1];
135
        }
136
137
        if (preg_match('#^/api/courses/(\d+)(?:/|$)#', $path, $m)) {
138
            return (int) $m[1];
139
        }
140
141
        return 0;
142
    }
143
144
    private function isCoursePublic(int $cid): bool
145
    {
146
        /** @var Course|null $course */
147
        $course = $this->em->getRepository(Course::class)->find($cid);
148
        return $course?->isPublic() ?? false;
149
    }
150
151
    /** Store the active public course context in session and renew TTL. */
152
    private function rememberActivePublicCid(Request $request, int $cid): void
153
    {
154
        $session = $request->getSession();
155
        $session->set(self::S_ACTIVE_CID, $cid);
156
        $session->set(self::S_ACTIVE_PUBLIC, true);
157
        $session->set(self::S_ACTIVE_EXPIRES_AT, time() + self::ACTIVE_TTL_SECONDS);
158
    }
159
160
    /** Log in as an anonymous entity User (create/reuse and set a UsernamePasswordToken). */
161
    private function loginAnonymousEntity(Request $request): void
162
    {
163
        $userIp = $request->getClientIp() ?: '127.0.0.1';
164
        $anonId = $this->getOrCreateAnonymousUserId($userIp);
165
        if (null === $anonId) {
166
            return;
167
        }
168
169
        // Register login if it doesn't exist yet
170
        $trackRepo = $this->em->getRepository(TrackELogin::class);
171
        if (!$trackRepo->findOneBy(['userIp' => $userIp, 'user' => $anonId])) {
172
            $trackLogin = (new TrackELogin())
173
                ->setUserIp($userIp)
174
                ->setLoginDate(new DateTime())
175
                ->setUser($this->em->getReference(User::class, $anonId));
176
            $this->em->persist($trackLogin);
177
            $this->em->flush();
178
        }
179
180
        // Set token
181
        $userRepo = $this->em->getRepository(User::class);
182
        $user     = $userRepo->find($anonId);
183
        if (!$user) {
184
            return;
185
        }
186
187
        if ($request->hasSession()) {
188
            $request->getSession()->set('_user', [
189
                'user_id'         => $user->getId(),
190
                'username'        => $user->getUsername(),
191
                'firstname'       => $user->getFirstname(),
192
                'lastname'        => $user->getLastname(),
193
                'firstName'       => $user->getFirstname(),
194
                'lastName'        => $user->getLastname(),
195
                'email'           => $user->getEmail(),
196
                'official_code'   => $user->getOfficialCode(),
197
                'picture_uri'     => $user->getPictureUri(),
198
                'status'          => $user->getStatus(),
199
                'active'          => $user->isActive(),
200
                'theme'           => $user->getTheme(),
201
                'language'        => $user->getLocale(),
202
                'created_at'      => $user->getCreatedAt()->format('Y-m-d H:i:s'),
203
                'expiration_date' => $user->getExpirationDate() ? $user->getExpirationDate()->format('Y-m-d H:i:s') : null,
204
                'last_login'      => $user->getLastLogin() ? $user->getLastLogin()->format('Y-m-d H:i:s') : null,
205
                'is_anonymous'    => true,
206
            ]);
207
        }
208
209
        $roles = $user->getRoles();
210
        $this->tokenStorage->setToken(new UsernamePasswordToken($user, self::FIREWALL_NAME, $roles));
211
    }
212
213
    /** Clear token and session flags when navigating away from the course (top-level document navigation). */
214
    private function clearAnon(Request $request): void
215
    {
216
        $this->tokenStorage->setToken(null);
217
218
        if ($request->hasSession()) {
219
            $session = $request->getSession();
220
            $session->remove('_user');
221
            $session->remove(self::S_SECURITY_TOKEN);
222
            $session->remove(self::S_ACTIVE_CID);
223
            $session->remove(self::S_ACTIVE_PUBLIC);
224
            $session->remove(self::S_ACTIVE_EXPIRES_AT);
225
        }
226
    }
227
228
    /**
229
     * Consider it “top-level document navigation” if:
230
     *  - It is NOT an XHR (no `X-Requested-With: XMLHttpRequest`)
231
     *  - and browser sends `Sec-Fetch-Mode: navigate` and `Sec-Fetch-Dest: document`
232
     *  - or the Accept header includes `text/html`
233
     */
234
    private function isTopLevelNavigation(Request $request): bool
235
    {
236
        if ($request->isXmlHttpRequest()) {
237
            return false;
238
        }
239
240
        $mode = (string) $request->headers->get('Sec-Fetch-Mode', '');
241
        $dest = (string) $request->headers->get('Sec-Fetch-Dest', '');
242
        if ($mode === 'navigate' && $dest === 'document') {
243
            return true;
244
        }
245
246
        $accept = (string) $request->headers->get('Accept', '');
247
        return str_contains($accept, 'text/html');
248
    }
249
250
    private function getOrCreateAnonymousUserId(string $userIp): ?int
251
    {
252
        $userRepo   = $this->em->getRepository(User::class);
253
        $trackRepo  = $this->em->getRepository(TrackELogin::class);
254
        $autoProv   = 'true' === $this->settings->getSetting('security.anonymous_autoprovisioning');
255
256
        if (!$autoProv) {
257
            $u = $userRepo->findOneBy(['status' => User::ANONYMOUS], ['createdAt' => 'ASC']);
258
            return $u ? $u->getId() : $this->createAnonymousUser()->getId();
259
        }
260
261
        $max  = (int) $this->settings->getSetting('admin.max_anonymous_users') ?: self::MAX_ANONYMOUS_USERS;
262
        $list = $userRepo->findBy(['status' => User::ANONYMOUS], ['createdAt' => 'ASC']);
263
264
        // Reuse by IP if there is a previous login record
265
        foreach ($list as $u) {
266
            if ($trackRepo->findOneBy(['userIp' => $userIp, 'user' => $u])) {
267
                return $u->getId();
268
            }
269
        }
270
271
        // Trim excess anonymous users
272
        while (\count($list) >= $max) {
273
            $oldest = array_shift($list);
274
            if ($oldest) {
275
                $this->em->remove($oldest);
276
                $this->em->flush();
277
            }
278
        }
279
280
        return $this->createAnonymousUser()->getId();
281
    }
282
283
    private function createAnonymousUser(): User
284
    {
285
        $uniqueId = uniqid('anon_');
286
        $email    = $uniqueId.'@localhost.local';
287
288
        if ('true' === $this->settings->getSetting('profile.login_is_email')) {
289
            $uniqueId = $email;
290
        }
291
292
        $anonymousUser = (new User())
293
            ->setSkipResourceNode(true)
294
            ->setLastname('Doe')
295
            ->setFirstname('Anonymous')
296
            ->setUsername('anon_'.$uniqueId)
297
            ->setStatus(User::ANONYMOUS)
298
            ->setPlainPassword('anon')
299
            ->setEmail($email)
300
            ->setOfficialCode('anonymous')
301
            ->setCreatorId(1)
302
            ->addRole('ROLE_ANONYMOUS');
303
304
        $this->em->persist($anonymousUser);
305
        $this->em->flush();
306
307
        return $anonymousUser;
308
    }
309
}
310