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

RememberMeCookieAuthenticator::shouldSkipPath()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 5
nc 5
nop 1
dl 0
loc 7
rs 9.6111
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\Security\Authenticator;
8
9
use Chamilo\CoreBundle\Entity\User;
10
use Chamilo\CoreBundle\Entity\ValidationToken;
11
use Chamilo\CoreBundle\Repository\Node\UserRepository;
12
use Chamilo\CoreBundle\Repository\ValidationTokenRepository;
13
use DateTimeImmutable;
14
use Symfony\Component\DependencyInjection\Attribute\Autowire;
15
use Symfony\Component\HttpFoundation\Request;
16
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
17
use Symfony\Component\Security\Core\Exception\AuthenticationException;
18
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
19
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
20
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
21
22
final class RememberMeCookieAuthenticator extends AbstractAuthenticator
23
{
24
    private const COOKIE_NAME = 'ch_remember_me';
25
26
    /**
27
     * 14 days.
28
     */
29
    private const TTL_SECONDS = 1209600;
30
31
    public function __construct(
32
        private readonly ValidationTokenRepository $tokenRepository,
33
        private readonly UserRepository $userRepository,
34
        private readonly TokenStorageInterface $tokenStorage,
35
        #[Autowire('%env(APP_SECRET)%')]
36
        private readonly string $appSecret,
37
    ) {}
38
39
    public function supports(Request $request): ?bool
40
    {
41
        $path = $request->getPathInfo();
42
        if ($this->shouldSkipPath($path)) {
43
            return false;
44
        }
45
46
        // If already authenticated as a real user, do nothing.
47
        $currentToken = $this->tokenStorage->getToken();
48
        if ($currentToken && $currentToken->getUser() instanceof User) {
49
            return false;
50
        }
51
52
        $cookieValue = $request->cookies->get(self::COOKIE_NAME);
53
54
        return \is_string($cookieValue) && '' !== $cookieValue;
55
    }
56
57
    public function authenticate(Request $request): SelfValidatingPassport
58
    {
59
        $cookieValue = (string) $request->cookies->get(self::COOKIE_NAME);
60
61
        $parsed = $this->parseCookieValue($cookieValue);
62
        if (null === $parsed) {
63
            $request->attributes->set('_remember_me_clear', true);
64
            throw new AuthenticationException('Invalid remember-me cookie format/signature.');
65
        }
66
67
        $userId = (int) $parsed['userId'];
68
        $rawToken = (string) $parsed['token'];
69
        $hash = hash('sha256', $rawToken);
70
71
        // Opportunistic cleanup.
72
        $cutoff = (new DateTimeImmutable())->modify('-'.self::TTL_SECONDS.' seconds');
73
        $this->tokenRepository->deleteExpiredRememberMeTokens($cutoff);
74
75
        $tokenEntity = $this->tokenRepository->findRememberMeToken($userId, $hash);
76
        if (!$tokenEntity instanceof ValidationToken) {
77
            $request->attributes->set('_remember_me_clear', true);
78
            throw new AuthenticationException('Remember-me token not found.');
79
        }
80
81
        if ($this->isExpired($tokenEntity)) {
82
            $this->tokenRepository->remove($tokenEntity, true);
83
            $request->attributes->set('_remember_me_clear', true);
84
            throw new AuthenticationException('Remember-me token expired.');
85
        }
86
87
        $user = $this->userRepository->find($userId);
88
        if (!$user instanceof User) {
89
            $this->tokenRepository->remove($tokenEntity, true);
90
            $request->attributes->set('_remember_me_clear', true);
91
            throw new AuthenticationException('User not found for remember-me token.');
92
        }
93
94
        // Safety checks.
95
        if (User::ACTIVE !== $user->getActive()) {
96
            $this->tokenRepository->remove($tokenEntity, true);
97
            $request->attributes->set('_remember_me_clear', true);
98
            throw new AuthenticationException('User not active.');
99
        }
100
101
        if (null !== $user->getExpirationDate() && $user->getExpirationDate() <= new \DateTime()) {
102
            $this->tokenRepository->remove($tokenEntity, true);
103
            $request->attributes->set('_remember_me_clear', true);
104
            throw new AuthenticationException('User expired.');
105
        }
106
107
        // Prepare rotation (subscriber will commit rotation + set cookie on response).
108
        $newRaw = $this->generateRawToken();
109
        $newHash = hash('sha256', $newRaw);
110
        $newCookieValue = $this->buildCookieValue($userId, $newRaw);
111
112
        $request->attributes->set('_remember_me_rotate', [
113
            'oldId' => $tokenEntity->getId(),
114
            'userId' => $userId,
115
            'newHash' => $newHash,
116
            'newCookie' => $newCookieValue,
117
        ]);
118
119
        return new SelfValidatingPassport(
120
            new UserBadge((string) $userId, static fn () => $user)
121
        );
122
    }
123
124
    public function onAuthenticationSuccess(Request $request, $token, string $firewallName): ?\Symfony\Component\HttpFoundation\Response
125
    {
126
        return null;
127
    }
128
129
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?\Symfony\Component\HttpFoundation\Response
130
    {
131
        $request->attributes->set('_remember_me_clear', true);
132
        return null;
133
    }
134
135
    private function shouldSkipPath(string $path): bool
136
    {
137
        return str_starts_with($path, '/login')
138
            || str_starts_with($path, '/logout')
139
            || str_starts_with($path, '/validate')
140
            || str_starts_with($path, '/_wdt')
141
            || str_starts_with($path, '/_profiler');
142
    }
143
144
    private function isExpired(ValidationToken $token): bool
145
    {
146
        $createdTs = $token->getCreatedAt()->getTimestamp();
147
        return ($createdTs + self::TTL_SECONDS) < time();
148
    }
149
150
    private function parseCookieValue(string $value): ?array
151
    {
152
        $parts = explode(':', $value);
153
        if (3 !== \count($parts)) {
154
            return null;
155
        }
156
157
        [$userIdRaw, $token, $sig] = $parts;
158
159
        if (!ctype_digit($userIdRaw)) {
160
            return null;
161
        }
162
163
        $userId = (int) $userIdRaw;
164
        if ($userId <= 0 || '' === $token || '' === $sig) {
165
            return null;
166
        }
167
168
        $expected = $this->sign($userIdRaw.'|'.$token);
169
        if (!hash_equals($expected, $sig)) {
170
            return null;
171
        }
172
173
        return ['userId' => $userId, 'token' => $token];
174
    }
175
176
    private function buildCookieValue(int $userId, string $rawToken): string
177
    {
178
        $sig = $this->sign((string) $userId.'|'.$rawToken);
179
        return $userId.':'.$rawToken.':'.$sig;
180
    }
181
182
    private function sign(string $data): string
183
    {
184
        $raw = hash_hmac('sha256', $data, $this->appSecret, true);
185
        return $this->base64UrlEncode($raw);
186
    }
187
188
    private function generateRawToken(): string
189
    {
190
        return $this->base64UrlEncode(random_bytes(32));
191
    }
192
193
    private function base64UrlEncode(string $raw): string
194
    {
195
        return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
196
    }
197
}
198