Passed
Pull Request — master (#244)
by Alexander
02:43
created

Cookie   C

Complexity

Total Complexity 56

Size/Duplication

Total Lines 530
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 139
dl 0
loc 530
ccs 55
cts 55
cp 1
rs 5.5199
c 3
b 0
f 0
wmc 56

27 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 22 3
A withValue() 0 5 1
A setValue() 0 8 2
A withExpires() 0 6 1
A isExpired() 0 3 2
A getValue() 0 3 1
A withMaxAge() 0 5 1
A getName() 0 3 1
A getExpires() 0 4 2
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 withSecure() 0 5 1
A setPath() 0 7 3
A getSameSite() 0 3 1
A withSameSite() 0 5 1
C fromCookieString() 0 62 14
A withDomain() 0 5 1
A isSecure() 0 3 1
A splitCookieAttribute() 0 6 1
A expire() 0 5 1
A getDomain() 0 3 1
A setSameSite() 0 15 4
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
 * Represents a cookie and also helps adding Set-Cookie header to response in order to set a 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
     * Returns modified cookie that will expire immediately.
266
     * @return static
267
     */
268
    public function expire(): self
269
    {
270
        $new = clone $this;
271
        $new->expires = new DateTimeImmutable('-1 year');
272
        return $new;
273
    }
274
275
    /**
276
     * Will remove the expiration from the cookie which will convert the cookie
277
     * to session cookie, which will expire as soon as the browser is closed.
278
     *
279
     * @return static
280
     */
281
    public function expireWhenBrowserIsClosed(): self
282
    {
283
        $new = clone $this;
284
        $new->expires = null;
285
        return $new;
286
    }
287
288
289
    /**
290
     * Creates a cookie copy with a new domain set.
291
     *
292
     * @param string $domain
293
     * @return static
294
     */
295
    public function withDomain(string $domain): self
296
    {
297
        $new = clone $this;
298
        $new->domain = $domain;
299
        return $new;
300
    }
301
302
    /**
303
     * Gets the domain of the cookie.
304
     *
305
     * @return string|null
306
     */
307
    public function getDomain(): ?string
308
    {
309
        return $this->domain;
310
    }
311
312
    /**
313
     * Creates a cookie copy with a new path set.
314
     *
315
     * @param string $path to be set for the cookie
316
     * @return static
317
     * @see $path for more information.
318
     */
319
    public function withPath(string $path): self
320
    {
321
        $new = clone $this;
322
        $new->setPath($path);
323
        return $new;
324
    }
325
326
    private function setPath(?string $path): void
327
    {
328
        if ($path !== null && preg_match('/[\x00-\x1F\x7F\x3B]/', $path)) {
329
            throw new InvalidArgumentException("The cookie path \"$path\" contains invalid characters.");
330
        }
331
332
        $this->path = $path;
333
    }
334
335
    /**
336
     * Gets the path of the cookie
337
     *
338
     * @return string|null
339
     */
340
    public function getPath(): ?string
341
    {
342
        return $this->path;
343
    }
344
345
    /**
346
     * Creates a cookie copy by making it secure or insecure.
347
     *
348
     * @param bool $secure whether the cookie must be secure.
349
     * @return static
350
     */
351
    public function withSecure(bool $secure = true): self
352
    {
353
        $new = clone $this;
354
        $new->secure = $secure;
355
        return $new;
356
    }
357
358
    /**
359
     * Whether the cookie is secure.
360
     *
361
     * @return bool
362
     */
363
    public function isSecure(): bool
364
    {
365
        return $this->secure ?? false;
366
    }
367
368
    /**
369
     * Creates a cookie copy that would be accessible only through the HTTP protocol.
370
     *
371
     * @param bool $httpOnly
372
     * @return static
373
     */
374
    public function withHttpOnly(bool $httpOnly = true): self
375
    {
376
        $new = clone $this;
377
        $new->httpOnly = $httpOnly;
378
        return $new;
379
    }
380
381
    /**
382
     * Whether the cookie can be accessed only through the HTTP protocol.
383
     *
384
     * @return bool
385
     */
386
    public function isHttpOnly(): bool
387
    {
388
        return $this->httpOnly ?? false;
389
    }
390
391
    /**
392
     * Creates a cookie copy with SameSite attribute.
393
     *
394
     * @param string $sameSite
395
     * @return static
396
     */
397
    public function withSameSite(string $sameSite): self
398
    {
399
        $new = clone $this;
400
        $new->setSameSite($sameSite);
401
        return $new;
402
    }
403
404
    private function setSameSite(?string $sameSite): void
405
    {
406
        if ($sameSite !== null
407
            && !in_array($sameSite, [self::SAME_SITE_LAX, self::SAME_SITE_STRICT, self::SAME_SITE_NONE], true)) {
408
            throw new InvalidArgumentException('sameSite should be one of "Lax", "Strict" or "None"');
409
        }
410
411
        if ($sameSite === self::SAME_SITE_NONE) {
412
            // the secure flag is required for cookies that are marked as 'SameSite=None'
413
            // so that cross-site cookies can only be accessed over HTTPS
414
            // without it cookie will not be available for external access
415
            $this->secure = true;
416
        }
417
418
        $this->sameSite = $sameSite;
419
    }
420
421
    /**
422
     * Gets the SameSite attribute
423
     *
424
     * @return string|null
425
     */
426
    public function getSameSite(): ?string
427
    {
428
        return $this->sameSite;
429
    }
430
431
    /**
432
     * Adds the cookie to the response and returns it.
433
     *
434
     * @param ResponseInterface $response
435
     * @return ResponseInterface response with added cookie.
436
     */
437
    public function addToResponse(ResponseInterface $response): ResponseInterface
438
    {
439
        return $response->withAddedHeader('Set-Cookie', (string) $this);
440
    }
441
442
    /**
443
     * Returns the cookie as a string.
444
     *
445
     * @return string The cookie
446
     */
447
    public function __toString(): string
448
    {
449
        $cookieParts = [
450
            $this->name . '=' . $this->value
451
        ];
452
453
        if ($this->expires !== null) {
454
            $cookieParts[] = 'Expires=' . $this->expires->format(DateTimeInterface::RFC7231);
455
            $cookieParts[] = 'Max-Age=' . ($this->expires->getTimestamp() - time());
456
        }
457
458
        if ($this->domain !== null) {
459
            $cookieParts[] = 'Domain=' . $this->domain;
460
        }
461
462
        if ($this->path !== null) {
463
            $cookieParts[] = 'Path=' . $this->path;
464
        }
465
466
        if ($this->secure) {
467
            $cookieParts[] = 'Secure';
468
        }
469
470
        if ($this->httpOnly) {
471
            $cookieParts[] = 'HttpOnly';
472
        }
473
474
        if ($this->sameSite !== null) {
475
            $cookieParts[] = 'SameSite=' . $this->sameSite;
476
        }
477
478
        return implode('; ', $cookieParts);
479
    }
480
481
    /**
482
     * Parse `Set-Cookie` string and build Cookie object.
483
     *
484
     * @param string $string `Set-Cookie` header value to parse
485
     * @return static
486
     * @throws Exception
487
     */
488
    public static function fromCookieString(string $string): self
489
    {
490
        $rawAttributes = preg_split('~\s*[;]\s*~', $string);
491
        if ($rawAttributes === false) {
492
            throw new InvalidArgumentException('Failed to parse Set-Cookie string ' . $string);
493
        }
494
        // array_filter with empty callback is used to filter out all falsy values
495
        $rawAttributes = array_filter($rawAttributes);
496
497
        $rawAttribute = array_shift($rawAttributes);
498
499
        if (!is_string($rawAttribute)) {
500
            throw new InvalidArgumentException('Cookie string must have at least name');
501
        }
502
503
        [$cookieName, $cookieValue] = self::splitCookieAttribute($rawAttribute);
504
505
        $params = [
506
            'name' => $cookieName,
507
            'value' => $cookieValue ?? '',
508
            'expires' => null,
509
            'domain' => null,
510
            'path' => null,
511
            'secure' => null,
512
            'httpOnly' => null,
513
            'sameSite' => null,
514
        ];
515
516
        while ($rawAttribute = array_shift($rawAttributes)) {
517
            [$attributeKey, $attributeValue] = self::splitCookieAttribute($rawAttribute);
518
            $attributeKey = strtolower($attributeKey);
519
520
            if ($attributeValue === null && ($attributeKey !== 'secure' || $attributeKey !== 'httponly')) {
521
                continue;
522
            }
523
524
            switch (strtolower($attributeKey)) {
525
                case 'expires':
526
                    $params['expires'] = new DateTimeImmutable($attributeValue);
527
                    break;
528
                case 'max-age':
529
                    $params['expires'] = (new DateTimeImmutable())->setTimestamp(time() + (int) $attributeValue);
530
                    break;
531
                case 'domain':
532
                    $params['domain'] = $attributeValue;
533
                    break;
534
                case 'path':
535
                    $params['path'] = $attributeValue;
536
                    break;
537
                case 'secure':
538
                    $params['secure'] = true;
539
                    break;
540
                case 'httponly':
541
                    $params['httpOnly'] = true;
542
                    break;
543
                case 'samesite':
544
                    $params['sameSite'] = $attributeValue;
545
                    break;
546
            }
547
        }
548
549
        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

549
        return new self(/** @scrutinizer ignore-type */ ...array_values($params));
Loading history...
550
    }
551
552
    private static function splitCookieAttribute(string $attribute): array
553
    {
554
        $parts = explode('=', $attribute, 2);
555
        $parts[1] = $parts[1] ?? null;
556
557
        return array_map('urldecode', $parts);
558
    }
559
}
560