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

RememberMeSubscriber::parseCookieValue()   B

Complexity

Conditions 7
Paths 5

Size

Total Lines 24
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 7
eloc 13
nc 5
nop 1
dl 0
loc 24
rs 8.8333
c 1
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
namespace Chamilo\CoreBundle\EventSubscriber;
8
9
use Chamilo\CoreBundle\Entity\User;
10
use Chamilo\CoreBundle\Entity\ValidationToken;
11
use Chamilo\CoreBundle\Helpers\ValidationTokenHelper;
12
use Chamilo\CoreBundle\Repository\Node\UserRepository;
13
use Chamilo\CoreBundle\Repository\ValidationTokenRepository;
14
use DateTimeImmutable;
15
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
16
use Symfony\Component\HttpFoundation\Cookie;
17
use Symfony\Component\HttpFoundation\Request;
18
use Symfony\Component\HttpKernel\Event\RequestEvent;
19
use Symfony\Component\HttpKernel\Event\ResponseEvent;
20
use Symfony\Component\HttpKernel\KernelEvents;
21
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
22
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
23
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
24
use Symfony\Component\Security\Http\Event\LogoutEvent;
25
use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterface;
26
27
final class RememberMeSubscriber implements EventSubscriberInterface
28
{
29
    private const COOKIE_NAME = 'ch_remember_me';
30
31
    /**
32
     * Extended session lifetime (seconds).
33
     * 14 days = 14 * 24 * 60 * 60 = 1209600
34
     */
35
    private const TTL_SECONDS = 1209600;
36
37
    // Toggle to debug quickly via PHP error log.
38
    private const DEBUG = false;
39
40
    public function __construct(
41
        private readonly ValidationTokenRepository $tokenRepository,
42
        private readonly UserRepository $userRepository,
43
        private readonly TokenStorageInterface $tokenStorage,
44
        private readonly SessionAuthenticationStrategyInterface $sessionStrategy,
45
        private readonly string $appSecret,
46
    ) {}
47
48
    public static function getSubscribedEvents(): array
49
    {
50
        return [
51
            // Run AFTER the firewall ContextListener loads the session token.
52
            KernelEvents::REQUEST => ['onKernelRequest', -10],
53
            KernelEvents::RESPONSE => ['onKernelResponse', 0],
54
            LoginSuccessEvent::class => ['onLoginSuccess', 0],
55
            LogoutEvent::class => ['onLogout', 0],
56
        ];
57
    }
58
59
    public function onLoginSuccess(LoginSuccessEvent $event): void
60
    {
61
        $request = $event->getRequest();
62
        $token = $event->getAuthenticatedToken();
63
        $user = $token?->getUser();
64
65
        if (!$user instanceof User) {
66
            return;
67
        }
68
69
        if (!$this->isRememberMeRequested($request)) {
70
            return;
71
        }
72
73
        $secure = $this->isRequestSecure($request);
74
75
        $userId = (int) $user->getId();
76
        $raw = $this->generateRawToken();
77
        $hash = hash('sha256', $raw);
78
        $cookieValue = $this->buildCookieValue($userId, $raw);
79
80
        // Keep it simple: one active token per user.
81
        $this->tokenRepository->deleteRememberMeTokensForUser($userId);
82
83
        $tokenEntity = new ValidationToken(ValidationTokenHelper::TYPE_REMEMBER_ME, $userId, $hash);
84
        $this->tokenRepository->save($tokenEntity, true);
85
86
        $expiresAt = (new DateTimeImmutable())->modify('+'.self::TTL_SECONDS.' seconds');
87
        $event->getResponse()->headers->setCookie($this->makeRememberMeCookie($cookieValue, $expiresAt, $secure));
88
89
        $this->debug('Issued remember-me cookie on login success', [
90
            'userId' => $userId,
91
            'secure' => $secure,
92
        ]);
93
    }
94
95
    public function onKernelRequest(RequestEvent $event): void
96
    {
97
        if (!$event->isMainRequest()) {
98
            return;
99
        }
100
101
        $request = $event->getRequest();
102
103
        $path = $request->getPathInfo();
104
        if ($this->shouldSkipPath($path)) {
105
            return;
106
        }
107
108
        $currentToken = $this->tokenStorage->getToken();
109
        if ($currentToken && $currentToken->getUser() instanceof User) {
110
            return; // Already authenticated as a real user
111
        }
112
113
        $cookieValue = $request->cookies->get(self::COOKIE_NAME);
114
        if (!\is_string($cookieValue) || '' === $cookieValue) {
115
            $this->debug('No remember-me cookie on request');
116
            return;
117
        }
118
119
        $parsed = $this->parseCookieValue($cookieValue);
120
        if (null === $parsed) {
121
            $request->attributes->set('_remember_me_clear', true);
122
            $this->debug('Invalid remember-me cookie format/signature');
123
            return;
124
        }
125
126
        $userId = $parsed['userId'];
127
        $rawToken = $parsed['token'];
128
        $hash = hash('sha256', $rawToken);
129
130
        // Opportunistic cleanup.
131
        $cutoff = (new DateTimeImmutable())->modify('-'.self::TTL_SECONDS.' seconds');
132
        $this->tokenRepository->deleteExpiredRememberMeTokens($cutoff);
133
134
        $tokenEntity = $this->tokenRepository->findRememberMeToken($userId, $hash);
135
        if (!$tokenEntity) {
136
            $request->attributes->set('_remember_me_clear', true);
137
            $this->debug('Remember-me token not found in DB', ['userId' => $userId]);
138
            return;
139
        }
140
141
        if ($this->isExpired($tokenEntity)) {
142
            $this->tokenRepository->remove($tokenEntity, true);
143
            $request->attributes->set('_remember_me_clear', true);
144
            $this->debug('Remember-me token expired', ['userId' => $userId]);
145
            return;
146
        }
147
148
        $user = $this->userRepository->find($userId);
149
        if (!$user instanceof User) {
150
            $this->tokenRepository->remove($tokenEntity, true);
151
            $request->attributes->set('_remember_me_clear', true);
152
            $this->debug('User not found for remember-me token', ['userId' => $userId]);
153
            return;
154
        }
155
156
        // Basic safety checks.
157
        if (User::ACTIVE !== $user->getActive()) {
158
            $this->tokenRepository->remove($tokenEntity, true);
159
            $request->attributes->set('_remember_me_clear', true);
160
            $this->debug('User not active for remember-me token', ['userId' => $userId]);
161
            return;
162
        }
163
164
        if (null !== $user->getExpirationDate() && $user->getExpirationDate() <= new \DateTime()) {
165
            $this->tokenRepository->remove($tokenEntity, true);
166
            $request->attributes->set('_remember_me_clear', true);
167
            $this->debug('User expired for remember-me token', ['userId' => $userId]);
168
            return;
169
        }
170
171
        // Authenticate user.
172
        $firewallName = $this->guessFirewallName($request);
173
        $securityToken = new UsernamePasswordToken($user, $firewallName, $user->getRoles());
174
        $this->tokenStorage->setToken($securityToken);
175
176
        // Prevent session fixation.
177
        if ($request->hasSession()) {
178
            $request->getSession()->start();
179
        }
180
        $this->sessionStrategy->onAuthentication($request, $securityToken);
181
182
        // Rotate token (commit on RESPONSE).
183
        $newRaw = $this->generateRawToken();
184
        $newHash = hash('sha256', $newRaw);
185
        $newCookieValue = $this->buildCookieValue($userId, $newRaw);
186
187
        $request->attributes->set('_remember_me_rotate', [
188
            'oldId' => $tokenEntity->getId(),
189
            'userId' => $userId,
190
            'newHash' => $newHash,
191
            'newCookie' => $newCookieValue,
192
        ]);
193
194
        $this->debug('Remember-me authentication succeeded', [
195
            'userId' => $userId,
196
            'firewall' => $firewallName,
197
        ]);
198
    }
199
200
    public function onKernelResponse(ResponseEvent $event): void
201
    {
202
        if (!$event->isMainRequest()) {
203
            return;
204
        }
205
206
        $request = $event->getRequest();
207
        $response = $event->getResponse();
208
        $secure = $this->isRequestSecure($request);
209
210
        if ($request->attributes->getBoolean('_remember_me_clear')) {
211
            // Clear both variants to avoid issues when switching HTTP/HTTPS in dev.
212
            $response->headers->setCookie($this->makeExpiredCookie(true));
213
            $response->headers->setCookie($this->makeExpiredCookie(false));
214
            return;
215
        }
216
217
        $rotate = $request->attributes->get('_remember_me_rotate');
218
        if (!\is_array($rotate)) {
219
            return;
220
        }
221
222
        $oldId = (int) ($rotate['oldId'] ?? 0);
223
        $userId = (int) ($rotate['userId'] ?? 0);
224
        $newHash = (string) ($rotate['newHash'] ?? '');
225
        $newCookie = (string) ($rotate['newCookie'] ?? '');
226
227
        if ($oldId <= 0 || $userId <= 0 || '' === $newHash || '' === $newCookie) {
228
            return;
229
        }
230
231
        // Store new token first, then delete old token.
232
        $newTokenEntity = new ValidationToken(ValidationTokenHelper::TYPE_REMEMBER_ME, $userId, $newHash);
233
        $this->tokenRepository->save($newTokenEntity, true);
234
235
        $this->tokenRepository->deleteRememberMeTokenById($oldId);
236
237
        $expiresAt = (new DateTimeImmutable())->modify('+'.self::TTL_SECONDS.' seconds');
238
        $response->headers->setCookie($this->makeRememberMeCookie($newCookie, $expiresAt, $secure));
239
    }
240
241
    public function onLogout(LogoutEvent $event): void
242
    {
243
        $user = $event->getToken()?->getUser();
244
        if ($user instanceof User) {
245
            $this->tokenRepository->deleteRememberMeTokensForUser((int) $user->getId());
246
        }
247
248
        $secure = $this->isRequestSecure($event->getRequest());
249
        $event->getResponse()?->headers->setCookie($this->makeExpiredCookie($secure));
250
    }
251
252
    private function isRememberMeRequested(Request $request): bool
253
    {
254
        // Try common checkbox names (adapt if your form uses a different name).
255
        $value = $request->request->get('_remember_me')
256
            ?? $request->request->get('remember_me')
257
            ?? $request->request->get('rememberme')
258
            ?? $request->request->get('ch_remember_me');
259
260
        if (null === $value) {
261
            return false;
262
        }
263
264
        return \in_array((string) $value, ['1', 'on', 'yes', 'true'], true);
265
    }
266
267
    private function shouldSkipPath(string $path): bool
268
    {
269
        return str_starts_with($path, '/login')
270
            || str_starts_with($path, '/logout')
271
            || str_starts_with($path, '/validate')
272
            || str_starts_with($path, '/_wdt')
273
            || str_starts_with($path, '/_profiler');
274
    }
275
276
    private function isExpired(ValidationToken $token): bool
277
    {
278
        $createdTs = $token->getCreatedAt()->getTimestamp();
279
        return ($createdTs + self::TTL_SECONDS) < time();
280
    }
281
282
    private function guessFirewallName(Request $request): string
283
    {
284
        $context = $request->attributes->get('_firewall_context');
285
        if (\is_string($context) && preg_match('/\.([^.]+)$/', $context, $m)) {
286
            return (string) $m[1];
287
        }
288
289
        return 'main';
290
    }
291
292
    private function parseCookieValue(string $value): ?array
293
    {
294
        $parts = explode(':', $value);
295
        if (3 !== \count($parts)) {
296
            return null;
297
        }
298
299
        [$userIdRaw, $token, $sig] = $parts;
300
301
        if (!ctype_digit($userIdRaw)) {
302
            return null;
303
        }
304
305
        $userId = (int) $userIdRaw;
306
        if ($userId <= 0 || '' === $token || '' === $sig) {
307
            return null;
308
        }
309
310
        $expected = $this->sign($userIdRaw.'|'.$token);
311
        if (!hash_equals($expected, $sig)) {
312
            return null;
313
        }
314
315
        return ['userId' => $userId, 'token' => $token];
316
    }
317
318
    private function buildCookieValue(int $userId, string $rawToken): string
319
    {
320
        $sig = $this->sign((string) $userId.'|'.$rawToken);
321
        return $userId.':'.$rawToken.':'.$sig;
322
    }
323
324
    private function sign(string $data): string
325
    {
326
        $raw = hash_hmac('sha256', $data, $this->appSecret, true);
327
        return $this->base64UrlEncode($raw);
328
    }
329
330
    private function generateRawToken(): string
331
    {
332
        return $this->base64UrlEncode(random_bytes(32));
333
    }
334
335
    private function base64UrlEncode(string $raw): string
336
    {
337
        return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
338
    }
339
340
    private function makeRememberMeCookie(string $value, DateTimeImmutable $expiresAt, bool $secure): Cookie
341
    {
342
        return new Cookie(
343
            self::COOKIE_NAME,
344
            $value,
345
            $expiresAt->getTimestamp(),
346
            '/',
347
            null,
348
            $secure,
349
            true,
350
            false,
351
            Cookie::SAMESITE_LAX
352
        );
353
    }
354
355
    private function makeExpiredCookie(bool $secure): Cookie
356
    {
357
        return new Cookie(
358
            self::COOKIE_NAME,
359
            '',
360
            time() - 3600,
361
            '/',
362
            null,
363
            $secure,
364
            true,
365
            false,
366
            Cookie::SAMESITE_LAX
367
        );
368
    }
369
370
    private function isRequestSecure(Request $request): bool
371
    {
372
        if ($request->isSecure()) {
373
            return true;
374
        }
375
376
        // Helps when behind a reverse proxy and trusted proxies aren't configured.
377
        $xfp = strtolower((string) $request->headers->get('x-forwarded-proto', ''));
378
        return str_contains($xfp, 'https');
379
    }
380
381
    private function debug(string $message, array $context = []): void
382
    {
383
        if (!self::DEBUG) {
384
            return;
385
        }
386
387
        error_log('[remember-me] '.$message.' '.json_encode($context));
388
    }
389
}
390