Passed
Pull Request — master (#264)
by Alexander
10:32 queued 06:21
created

Cookie::fromCookieString()   C

Complexity

Conditions 14
Paths 22

Size

Total Lines 64
Code Lines 47

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 45
CRAP Score 14.002

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 14
eloc 47
c 1
b 0
f 0
nc 22
nop 1
dl 0
loc 64
ccs 45
cts 46
cp 0.9783
crap 14.002
rs 6.2666

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
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Web;
6
7
use DateInterval;
8
use DateTimeImmutable;
9
use DateTimeInterface;
10
use Exception;
11
use InvalidArgumentException;
12
use Psr\Http\Message\ResponseInterface;
13
14
use function array_filter;
15
use function array_shift;
16
use function explode;
17
use function implode;
18
use function in_array;
19
use function preg_match;
20
use function preg_split;
21
use function strtolower;
22
use function time;
23
24
/**
25
 * Represents a cookie and also helps adding Set-Cookie header to response in order to set a cookie.
26
 */
27
final class Cookie
28
{
29
    /**
30
     * Regular Expression used to validate cookie name
31
     * @link https://tools.ietf.org/html/rfc6265#section-4.1.1
32
     * @link https://tools.ietf.org/html/rfc2616#section-2.2
33
     */
34
    private const PATTERN_TOKEN = '/^[a-zA-Z0-9!#$%&\' * +\- .^_`|~]+$/';
35
36
    /**
37
     * SameSite policy `Lax` will prevent the cookie from being sent by the browser in all cross-site browsing contexts
38
     * during CSRF-prone request methods (e.g. POST, PUT, PATCH etc).
39
     * E.g. a POST request from https://otherdomain.com to https://yourdomain.com will not include the cookie, however a GET request will.
40
     * When a user follows a link from https://otherdomain.com to https://yourdomain.com it will include the cookie.
41
     * This is the default value in modern browsers.
42
     * @see $sameSite
43
     */
44
    public const SAME_SITE_LAX = 'Lax';
45
46
    /**
47
     * SameSite policy `Strict` will prevent the cookie from being sent by the browser in all cross-site browsing contexts
48
     * regardless of the request method and even when following a regular link.
49
     * E.g. a GET request from https://otherdomain.com to https://yourdomain.com or a user following a link from
50
     * https://otherdomain.com to https://yourdomain.com will not include the cookie.
51
     * @see $sameSite
52
     */
53
    public const SAME_SITE_STRICT = 'Strict';
54
55
    /**
56
     * SameSite policy `None` cookies will be sent in all contexts, i.e. sending cross-origin is allowed.
57
     * `None` requires the `Secure` attribute in latest browser versions.
58
     * @see $sameSite
59
     */
60
    public const SAME_SITE_NONE = 'None';
61
62
    /**
63
     * @var string name of the cookie.
64
     * A cookie name can be any US-ASCII characters, except control characters, spaces, or tabs.
65
     * It also must not contain a separator character like the following: ( ) < > @ , ; : \ " / [ ] ? = { }
66
     */
67
    private string $name;
68
69
    /**
70
     * @var string value of the cookie.
71
     */
72
    private string $value;
73
74
    /**
75
     * @var DateTimeInterface|null The maximum lifetime of the cookie.
76
     * If unspecified, the cookie becomes a session cookie, which will be removed
77
     * when the client shuts down.
78
     * @link https://tools.ietf.org/html/rfc6265#section-4.1.1
79
     * @link https://tools.ietf.org/html/rfc1123#page-55
80
     */
81
    private ?DateTimeInterface $expires = null;
82
83
    /**
84
     * @var string|null host/domain to which the cookie will be sent.
85
     * If omitted, client will default to the host of the current URL, not including subdomains.
86
     * Multiple host/domain values are not allowed, but if a domain is specified,
87
     * then subdomains are always included.
88
     */
89
    private ?string $domain = null;
90
91
    /**
92
     * @var string|null the path on the server in which the cookie will be available on.
93
     * A cookie path can include any US-ASCII characters excluding control characters and semicolon
94
     */
95
    private ?string $path = null;
96
97
    /**
98
     * @var bool|null whether cookie should be sent via secure connection.
99
     * A secure cookie is only sent to the server when a request is made with the https: scheme.
100
     */
101
    private ?bool $secure = null;
102
103
    /**
104
     * @var bool|null whether the cookie should be accessible only through the HTTP protocol.
105
     * By setting this property to true, the cookie will not be accessible by scripting languages,
106
     * such as JavaScript, which can effectively help to mitigate attacks against cross-site scripting (XSS).
107
     */
108
    private ?bool $httpOnly = null;
109
110
    /**
111
     * @var string|null asserts that a cookie must not be sent with cross-origin requests.
112
     * This provides some protection against cross-site request forgery attacks (CSRF)
113
     * @see https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#samesite-cookie-attribute for more information about sameSite.
114
     */
115
    private ?string $sameSite = null;
116
117
    /**
118
     * Cookie constructor.
119
     *
120
     * @param string $name The name of the cookie
121
     * @param string $value The value of of the cookie
122
     * @param DateTimeInterface|null $expires The time the cookie expires
123
     * @param string|null $domain The path on the server in which cookie will be available on
124
     * @param string|null $path The host/domain that the cookie is available to
125
     * @param bool|null $secure Whether the client should send back the cookie only over HTTPS connection
126
     * @param bool|null $httpOnly Whether the cookie should be accessible only through the HTTP protocol
127
     * @param string|null $sameSite Whether the cookie should be available for cross-site requests
128
     *
129
     * @throws InvalidArgumentException when one or more arguments are not valid
130
     */
131 46
    public function __construct(
132
        string $name,
133
        string $value = '',
134
        ?DateTimeInterface $expires = null,
135
        ?string $domain = null,
136
        ?string $path = '/',
137
        ?bool $secure = true,
138
        ?bool $httpOnly = true,
139
        ?string $sameSite = self::SAME_SITE_LAX
140
    ) {
141 46
        if (!preg_match(self::PATTERN_TOKEN, $name)) {
142 1
            throw new InvalidArgumentException("The cookie name \"$name\" contains invalid characters or is empty.");
143
        }
144
145 45
        $this->name = $name;
146 45
        $this->setValue($value);
147 45
        $this->expires = $expires !== null ? clone $expires : null;
148 45
        $this->domain = $domain;
149 45
        $this->setPath($path);
150 45
        $this->secure = $secure;
151 45
        $this->httpOnly = $httpOnly;
152 45
        $this->setSameSite($sameSite);
153 45
    }
154
155
    /**
156
     * Gets the name of the cookie.
157
     *
158
     * @return string
159
     */
160 21
    public function getName(): string
161
    {
162 21
        return $this->name;
163
    }
164
165
    /**
166
     * Creates a cookie copy with a new value.
167
     *
168
     * @param $value string value of the cookie.
169
     * @return static
170
     * @see $value for more information.
171
     */
172 4
    public function withValue(string $value): self
173
    {
174 4
        $new = clone $this;
175 4
        $new->setValue($value);
176 4
        return $new;
177
    }
178
179
    /**
180
     * Gets the value of the cookie.
181
     *
182
     * @return string
183
     */
184 4
    public function getValue(): string
185
    {
186 4
        return $this->value;
187
    }
188
189 45
    private function setValue(string $value): void
190
    {
191 45
        $this->value = $value;
192 45
    }
193
194
    /**
195
     * Creates a cookie copy with a new time the cookie expires.
196
     *
197
     * @param DateTimeInterface $dateTime
198
     * @return static
199
     * @see $expires for more inforemation.
200
     */
201 3
    public function withExpires(DateTimeInterface $dateTime): self
202
    {
203 3
        $new = clone $this;
204
        // Defensively clone the object to prevent further change
205 3
        $new->expires = clone $dateTime;
206 3
        return $new;
207
    }
208
209
    /**
210
     * Gets the expiry of the cookie.
211
     *
212
     * @return DateTimeImmutable|null
213
     */
214 1
    public function getExpires(): ?DateTimeImmutable
215
    {
216 1
        if ($this->expires === null) {
217 1
            return null;
218
        }
219
220
        // Can be replaced with DateTimeImmutable::createFromInterface in PHP 8
221
        // returns null on `setTimestamp()` failure
222 1
        return (new DateTimeImmutable())->setTimestamp($this->expires->getTimestamp()) ?: null;
223
    }
224
225
    /**
226
     * Indicates whether the cookie is expired.
227
     * The cookie is expired when it has outdated `Expires`, or
228
     * zero or negative `Max-Age` attributes
229
     *
230
     * @return bool whether the cookie is expired
231
     */
232 3
    public function isExpired(): bool
233
    {
234 3
        return $this->expires !== null && $this->expires->getTimestamp() < time();
235
    }
236
237
    /**
238
     * Creates a cookie copy with a new lifetime set.
239
     * If zero or negative interval is passed, the cookie will expire immediately.
240
     *
241
     * @param DateInterval $interval interval until the cookie expires.
242
     * @return static
243
     */
244 6
    public function withMaxAge(DateInterval $interval): self
245
    {
246 6
        $new = clone $this;
247 6
        $new->expires = (new DateTimeImmutable())->add($interval);
248 6
        return $new;
249
    }
250
251
    /**
252
     * Returns modified cookie that will expire immediately.
253
     * @return static
254
     */
255 5
    public function expire(): self
256
    {
257 5
        $new = clone $this;
258 5
        $new->expires = new DateTimeImmutable('-1 year');
259 5
        return $new;
260
    }
261
262
    /**
263
     * Will remove the expiration from the cookie which will convert the cookie
264
     * to session cookie, which will expire as soon as the browser is closed.
265
     *
266
     * @return static
267
     */
268 1
    public function expireWhenBrowserIsClosed(): self
269
    {
270 1
        $new = clone $this;
271 1
        $new->expires = null;
272 1
        return $new;
273
    }
274
275
276
    /**
277
     * Creates a cookie copy with a new domain set.
278
     *
279
     * @param string $domain
280
     * @return static
281
     */
282 3
    public function withDomain(string $domain): self
283
    {
284 3
        $new = clone $this;
285 3
        $new->domain = $domain;
286 3
        return $new;
287
    }
288
289
    /**
290
     * Gets the domain of the cookie.
291
     *
292
     * @return string|null
293
     */
294 1
    public function getDomain(): ?string
295
    {
296 1
        return $this->domain;
297
    }
298
299
    /**
300
     * Creates a cookie copy with a new path set.
301
     *
302
     * @param string $path to be set for the cookie
303
     * @return static
304
     * @see $path for more information.
305
     */
306 4
    public function withPath(string $path): self
307
    {
308 4
        $new = clone $this;
309 4
        $new->setPath($path);
310 3
        return $new;
311
    }
312
313 45
    private function setPath(?string $path): void
314
    {
315 45
        if ($path !== null && preg_match('/[\x00-\x1F\x7F\x3B]/', $path)) {
316 1
            throw new InvalidArgumentException("The cookie path \"$path\" contains invalid characters.");
317
        }
318
319 45
        $this->path = $path;
320 45
    }
321
322
    /**
323
     * Gets the path of the cookie
324
     *
325
     * @return string|null
326
     */
327 1
    public function getPath(): ?string
328
    {
329 1
        return $this->path;
330
    }
331
332
    /**
333
     * Creates a cookie copy by making it secure or insecure.
334
     *
335
     * @param bool $secure whether the cookie must be secure.
336
     * @return static
337
     */
338 3
    public function withSecure(bool $secure = true): self
339
    {
340 3
        $new = clone $this;
341 3
        $new->secure = $secure;
342 3
        return $new;
343
    }
344
345
    /**
346
     * Whether the cookie is secure.
347
     *
348
     * @return bool
349
     */
350 1
    public function isSecure(): bool
351
    {
352 1
        return $this->secure ?? false;
353
    }
354
355
    /**
356
     * Creates a cookie copy that would be accessible only through the HTTP protocol.
357
     *
358
     * @param bool $httpOnly
359
     * @return static
360
     */
361 3
    public function withHttpOnly(bool $httpOnly = true): self
362
    {
363 3
        $new = clone $this;
364 3
        $new->httpOnly = $httpOnly;
365 3
        return $new;
366
    }
367
368
    /**
369
     * Whether the cookie can be accessed only through the HTTP protocol.
370
     *
371
     * @return bool
372
     */
373 1
    public function isHttpOnly(): bool
374
    {
375 1
        return $this->httpOnly ?? false;
376
    }
377
378
    /**
379
     * Creates a cookie copy with SameSite attribute.
380
     *
381
     * @param string $sameSite
382
     * @return static
383
     */
384 4
    public function withSameSite(string $sameSite): self
385
    {
386 4
        $new = clone $this;
387 4
        $new->setSameSite($sameSite);
388 3
        return $new;
389
    }
390
391 45
    private function setSameSite(?string $sameSite): void
392
    {
393 45
        if ($sameSite !== null
394 45
            && !in_array($sameSite, [self::SAME_SITE_LAX, self::SAME_SITE_STRICT, self::SAME_SITE_NONE], true)) {
395 1
            throw new InvalidArgumentException('sameSite should be one of "Lax", "Strict" or "None"');
396
        }
397
398 45
        if ($sameSite === self::SAME_SITE_NONE) {
399
            // the secure flag is required for cookies that are marked as 'SameSite=None'
400
            // so that cross-site cookies can only be accessed over HTTPS
401
            // without it cookie will not be available for external access
402 1
            $this->secure = true;
403
        }
404
405 45
        $this->sameSite = $sameSite;
406 45
    }
407
408
    /**
409
     * Gets the SameSite attribute
410
     *
411
     * @return string|null
412
     */
413 1
    public function getSameSite(): ?string
414
    {
415 1
        return $this->sameSite;
416
    }
417
418
    /**
419
     * Adds the cookie to the response and returns it.
420
     *
421
     * @param ResponseInterface $response
422
     * @return ResponseInterface response with added cookie.
423
     */
424 20
    public function addToResponse(ResponseInterface $response): ResponseInterface
425
    {
426 20
        return $response->withAddedHeader('Set-Cookie', (string) $this);
427
    }
428
429
    /**
430
     * Returns the cookie as a string.
431
     *
432
     * @return string The cookie
433
     */
434 22
    public function __toString(): string
435
    {
436
        $cookieParts = [
437 22
            $this->name . '=' . urlencode($this->value)
438
        ];
439
440 22
        if ($this->expires !== null) {
441 13
            $cookieParts[] = 'Expires=' . $this->expires->format(DateTimeInterface::RFC7231);
442 13
            $cookieParts[] = 'Max-Age=' . ($this->expires->getTimestamp() - time());
443
        }
444
445 22
        if ($this->domain !== null) {
446 3
            $cookieParts[] = 'Domain=' . $this->domain;
447
        }
448
449 22
        if ($this->path !== null) {
450 22
            $cookieParts[] = 'Path=' . $this->path;
451
        }
452
453 22
        if ($this->secure) {
454 21
            $cookieParts[] = 'Secure';
455
        }
456
457 22
        if ($this->httpOnly) {
458 20
            $cookieParts[] = 'HttpOnly';
459
        }
460
461 22
        if ($this->sameSite !== null) {
462 22
            $cookieParts[] = 'SameSite=' . $this->sameSite;
463
        }
464
465 22
        return implode('; ', $cookieParts);
466
    }
467
468
    /**
469
     * Parse `Set-Cookie` string and build Cookie object.
470
     *
471
     * @param string $string `Set-Cookie` header value to parse
472
     * @return static
473
     * @throws Exception
474
     */
475 3
    public static function fromCookieString(string $string): self
476
    {
477 3
        $rawAttributes = preg_split('~\s*[;]\s*~', $string);
478 3
        if ($rawAttributes === false) {
479
            throw new InvalidArgumentException('Failed to parse Set-Cookie string ' . $string);
480
        }
481
        // array_filter with empty callback is used to filter out all falsy values
482 3
        $rawAttributes = array_filter($rawAttributes);
483
484 3
        $rawAttribute = array_shift($rawAttributes);
485
486 3
        if (!is_string($rawAttribute)) {
487 1
            throw new InvalidArgumentException('Cookie string must have at least name');
488
        }
489
490 2
        [$cookieName, $cookieValue] = self::splitCookieAttribute($rawAttribute);
491
492
        $params = [
493 2
            'name' => $cookieName,
494 2
            'value' => $cookieValue !== null ? urldecode($cookieValue) : '',
495
        ];
496
497 2
        while ($rawAttribute = array_shift($rawAttributes)) {
498 2
            [$attributeKey, $attributeValue] = self::splitCookieAttribute($rawAttribute);
499 2
            $attributeKey = strtolower($attributeKey);
500
501 2
            if ($attributeValue === null && !in_array($attributeKey, ['secure', 'httponly'], true)) {
502 1
                continue;
503
            }
504
505
            switch ($attributeKey) {
506 2
                case 'expires':
507 1
                    $params['expires'] = new DateTimeImmutable($attributeValue);
508 1
                    break;
509 2
                case 'max-age':
510 1
                    $params['expires'] = (new DateTimeImmutable())->setTimestamp(time() + (int) $attributeValue);
511 1
                    break;
512 2
                case 'domain':
513 1
                    $params['domain'] = $attributeValue;
514 1
                    break;
515 2
                case 'path':
516 2
                    $params['path'] = $attributeValue;
517 2
                    break;
518 2
                case 'secure':
519 2
                    $params['secure'] = true;
520 2
                    break;
521 2
                case 'httponly':
522 1
                    $params['httpOnly'] = true;
523 1
                    break;
524 2
                case 'samesite':
525 2
                    $params['sameSite'] = $attributeValue;
526 2
                    break;
527
            }
528
        }
529
530 2
        return new self(
531 2
            $params['name'],
532 2
            $params['value'],
533 2
            $params['expires'] ?? null,
534 2
            $params['domain'] ?? null,
535 2
            $params['path'] ?? null,
536 2
            $params['secure'] ?? null,
537 2
            $params['httpOnly'] ?? null,
538 2
            $params['sameSite'] ?? null
539
        );
540
    }
541
542 2
    private static function splitCookieAttribute(string $attribute): array
543
    {
544 2
        $parts = explode('=', $attribute, 2);
545 2
        $parts[1] = $parts[1] ?? null;
546
547 2
        return $parts;
548
    }
549
}
550