Passed
Pull Request — master (#244)
by
unknown
03:18
created

Cookie   C

Complexity

Total Complexity 55

Size/Duplication

Total Lines 519
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 136
dl 0
loc 519
rs 6
c 3
b 0
f 0
ccs 55
cts 55
cp 1
wmc 55

26 Methods

Rating   Name   Duplication   Size   Complexity  
B __toString() 0 32 7
A isHttpOnly() 0 3 1
A getPath() 0 3 1
A addToResponse() 0 3 1
A withHttpOnly() 0 5 1
A __construct() 0 22 3
A withValue() 0 5 1
A withSecure() 0 5 1
A setPath() 0 7 3
A getSameSite() 0 3 1
A setValue() 0 8 2
A withSameSite() 0 5 1
C fromCookieString() 0 62 14
A withExpires() 0 6 1
A isExpired() 0 3 2
A getValue() 0 3 1
A withDomain() 0 5 1
A isSecure() 0 3 1
A splitCookieAttribute() 0 6 1
A getDomain() 0 3 1
A withMaxAge() 0 5 1
A setSameSite() 0 15 4
A getName() 0 3 1
A getExpires() 0 4 2
A withPath() 0 5 1
A expireWhenBrowserIsClosed() 0 5 1

How to fix   Complexity   

Complex Class

Complex classes like Cookie often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Cookie, and based on these observations, apply Extract Interface, too.

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

538
        return new self(/** @scrutinizer ignore-type */ ...array_values($params));
Loading history...
539
    }
540
541
    private static function splitCookieAttribute(string $attribute): array
542
    {
543
        $parts = explode('=', $attribute, 2);
544
        $parts[1] = $parts[1] ?? null;
545
546
        return array_map('urldecode', $parts);
547
    }
548
}
549