Passed
Push — master ( 279881...b4f44a )
by Divine Niiquaye
10:48
created

RememberMeHandler::createRememberMeCookie()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 24
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 16
nc 4
nop 2
dl 0
loc 24
ccs 0
cts 17
cp 0
crap 20
rs 9.7333
c 1
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Biurad opensource projects.
7
 *
8
 * PHP version 7.4 and above required
9
 *
10
 * @author    Divine Niiquaye Ibok <[email protected]>
11
 * @copyright 2019 Biurad Group (https://biurad.com/)
12
 * @license   https://opensource.org/licenses/BSD-3-Clause License
13
 *
14
 * For the full copyright and license information, please view the LICENSE
15
 * file that was distributed with this source code.
16
 *
17
 */
18
19
namespace Biurad\Security\Handler;
20
21
use Psr\Http\Message\ServerRequestInterface;
22
use Symfony\Component\HttpFoundation\Cookie;
23
use Symfony\Component\Security\Core\Authentication\RememberMe\InMemoryTokenProvider;
24
use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentToken;
25
use Symfony\Component\Security\Core\Authentication\RememberMe\TokenProviderInterface;
26
use Symfony\Component\Security\Core\Authentication\RememberMe\TokenVerifierInterface;
27
use Symfony\Component\Security\Core\Exception\AuthenticationException;
28
use Symfony\Component\Security\Core\Exception\CookieTheftException;
29
use Symfony\Component\Security\Core\Signature\Exception\ExpiredSignatureException;
30
use Symfony\Component\Security\Core\Signature\Exception\InvalidSignatureException;
31
use Symfony\Component\Security\Core\Signature\SignatureHasher;
32
use Symfony\Component\Security\Core\User\UserInterface;
33
use Symfony\Component\Security\Core\User\UserProviderInterface;
34
35
/**
36
 * A remember handler to create and valid a user via a cookie.
37
 *
38
 * @author Divine Niiquaye Ibok <[email protected]>
39
 */
40
class RememberMeHandler
41
{
42
    public const COOKIE_DELIMITER = ':';
43
    public const REMEMBER_ME = '_security.remember_me';
44
45
    private ?TokenProviderInterface $tokenProvider;
46
    private ?TokenVerifierInterface $tokenVerifier;
47
    private ?SignatureHasher $signatureHasher;
48
    private Cookie $cookie;
49
    private string $secret, $parameterName;
50
51
    public function __construct(
52
        string $secret,
53
        TokenProviderInterface $tokenProvider = null,
54
        TokenVerifierInterface $tokenVerifier = null,
55
        SignatureHasher $signatureHasher = null,
56
        string $requestParameter = '_remember_me',
57
        array $options = []
58
    ) {
59
        $this->secret = $secret;
60
        $this->parameterName = $requestParameter;
61
        $this->tokenProvider = $tokenProvider ?? new InMemoryTokenProvider();
62
        $this->tokenVerifier = $tokenVerifier ?? ($this->tokenProvider instanceof TokenVerifierInterface ? $this->tokenProvider : null);
63
        $this->signatureHasher = $signatureHasher;
64
        $this->cookie = new Cookie(
65
            $options['name'] ?? 'REMEMBER_ME',
66
            null,
67
            $options['lifetime'] ?? 31536000,
68
            $options['path'] ?? '/',
69
            $options['domain'] ?? null,
70
            $options['secure'] ?? false,
71
            $options['httponly'] ?? true,
72
            false,
73
            $options['samesite'] ?? null
74
        );
75
    }
76
77
    public function getSecret(): string
78
    {
79
        return $this->secret;
80
    }
81
82
    public function getParameterName(): string
83
    {
84
        return $this->parameterName;
85
    }
86
87
    public function getCookieName(): string
88
    {
89
        return $this->cookie->getName();
90
    }
91
92
    /**
93
     * Returns the user and for every 2 minutes a new remember me cookie is included.
94
     *
95
     * @return array $user {0: UserInterface, 1: Cookie|null}
96
     */
97
    public function consumeRememberMeCookie(string $rawCookie, UserProviderInterface $userProvider): array
98
    {
99
        [, $identifier, $expires, $value] = self::fromRawCookie($rawCookie);
100
101
        if (!\str_contains($value, ':')) {
102
            throw new AuthenticationException('The cookie is incorrectly formatted.');
103
        }
104
        $user = $userProvider->loadUserByIdentifier($identifier);
105
106
        if (null !== $this->signatureHasher) {
107
            try {
108
                $this->signatureHasher->verifySignatureHash($user, $expires, $value);
109
            } catch (InvalidSignatureException $e) {
110
                throw new AuthenticationException('The cookie\'s hash is invalid.', 0, $e);
111
            } catch (ExpiredSignatureException $e) {
112
                throw new AuthenticationException('The cookie has expired.', 0, $e);
113
            }
114
        } elseif (null !== $this->tokenProvider) {
115
            [$series, $tokenValue] = \explode(':', $value);
116
            $persistentToken = $this->tokenProvider->loadTokenBySeries($series);
117
118
            if (null !== $this->tokenVerifier) {
119
                $isTokenValid = $this->tokenVerifier->verifyToken($persistentToken, $tokenValue);
120
            } else {
121
                $isTokenValid = \hash_equals($persistentToken->getTokenValue(), $tokenValue);
122
            }
123
124
            if (!$isTokenValid) {
125
                throw new CookieTheftException('This token was already used. The account is possibly compromised.');
126
            }
127
128
            if ($persistentToken->getLastUsed()->getTimestamp() + $this->cookie->getExpiresTime() < \time()) {
129
                throw new AuthenticationException('The cookie has expired.');
130
            }
131
132
            // if a token was regenerated less than 2 minutes ago, there is no need to regenerate it
133
            // if multiple concurrent requests reauthenticate a user we do not want to update the token several times
134
            if ($persistentToken->getLastUsed()->getTimestamp() + (60 * 2) < \time()) {
135
                $tokenValue = $this->generateHash();
136
                $tokenLastUsed = new \DateTime();
137
138
                if ($this->tokenVerifier) {
139
                    $this->tokenVerifier->updateExistingToken($persistentToken, $tokenValue, $tokenLastUsed);
140
                }
141
                $this->tokenProvider->updateToken($series, $tokenValue, $tokenLastUsed);
142
143
                return [$user, $this->createRememberMeCookie($user, $series . ':' . $tokenValue)];
144
            }
145
        } else {
146
            throw new \LogicException(\sprintf('Expected one of %s or %s class.', TokenProviderInterface::class, SignatureHasher::class));
147
        }
148
149
        return [$user, null];
150
    }
151
152
    public function createRememberMeCookie(UserInterface $user, string $value = null): Cookie
153
    {
154
        $expires = \time() + $this->cookie->getExpiresTime();
155
        $class = \get_class($user);
156
        $identifier = $user->getUserIdentifier();
157
158
        if (null !== $this->signatureHasher) {
159
            $value = $this->signatureHasher->computeSignatureHash($user, $expires);
160
        } elseif (null === $value) {
161
            if (null === $this->tokenProvider) {
162
                throw new \LogicException(\sprintf('Expected one of %s or %s class.', TokenProviderInterface::class, SignatureHasher::class));
163
            }
164
165
            $series = \base64_encode(\random_bytes(64));
166
            $tokenValue = $this->generateHash();
167
            $this->tokenProvider->createNewToken($token = new PersistentToken($class, $identifier, $series, $tokenValue, new \DateTime()));
168
            $value = $token->getSeries() . ':' . $token->getTokenValue();
169
        }
170
171
        $cookie = clone $this->cookie
172
            ->withValue(\base64_encode(\implode(self::COOKIE_DELIMITER, [$class, \base64_encode($identifier), $expires, $value])))
173
            ->withExpires($expires);
174
175
        return $this->setCookieName($cookie, $user->getUserIdentifier());
176
    }
177
178
    /**
179
     * @return array<int,Cookie>
180
     */
181
    public function clearRememberMeCookies(ServerRequestInterface $request): array
182
    {
183
        $cookies = [];
184
185
        foreach ($request->getCookieParams() as $cookieName => $rawCookie) {
186
            if (empty($rawCookie) || !\str_starts_with($cookieName, $this->getCookieName())) {
187
                continue;
188
            }
189
190
            $clearCookie = clone $this->cookie;
191
            $rememberMeDetails = self::fromRawCookie(\urldecode($rawCookie));
192
193
            if (null !== $this->tokenProvider) {
194
                [$series, ] = \explode(':', $rememberMeDetails[3]);
195
                $this->tokenProvider->deleteTokenBySeries($series);
196
            }
197
198
            $cookies[] = $this->setCookieName($clearCookie->withExpires(1)->withValue(null), $rememberMeDetails[1]);
199
        }
200
201
        return $cookies;
202
    }
203
204
    private static function fromRawCookie(string $rawCookie): array
205
    {
206
        $cookieParts = \explode(self::COOKIE_DELIMITER, \base64_decode($rawCookie), 4);
207
208
        if (false === $cookieParts[1] = \base64_decode($cookieParts[1], true)) {
209
            throw new AuthenticationException('The user identifier contains a character from outside the base64 alphabet.');
210
        }
211
212
        if (4 !== \count($cookieParts)) {
213
            throw new AuthenticationException('The cookie contains invalid data.');
214
        }
215
216
        return $cookieParts;
217
    }
218
219
    private function setCookieName(Cookie $cookie, string $userId): Cookie
220
    {
221
        return \Closure::bind(function (Cookie $cookie) use ($userId) {
222
            $cookie->name .= $userId;
0 ignored issues
show
Bug introduced by
The property name is declared protected in Symfony\Component\HttpFoundation\Cookie and cannot be accessed from this context.
Loading history...
223
224
            return $cookie;
225
        }, $cookie, $cookie)($cookie);
226
    }
227
228
    private function generateHash(): string
229
    {
230
        return hash_hmac('sha256', \base64_encode(\random_bytes(64)), $this->secret);
231
    }
232
}
233