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

Cookie   C

Complexity

Total Complexity 56

Size/Duplication

Total Lines 521
Duplicated Lines 0 %

Test Coverage

Coverage 99.36%

Importance

Changes 4
Bugs 0 Features 0
Metric Value
eloc 140
c 4
b 0
f 0
dl 0
loc 521
ccs 155
cts 156
cp 0.9936
rs 5.5199
wmc 56

27 Methods

Rating   Name   Duplication   Size   Complexity  
A getName() 0 3 1
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 3 1
A withSameSite() 0 5 1
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 expire() 0 5 1
A getDomain() 0 3 1
A withMaxAge() 0 5 1
A setSameSite() 0 15 4
A getExpires() 0 9 3
A withPath() 0 5 1
A expireWhenBrowserIsClosed() 0 5 1
C fromCookieString() 0 64 14

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_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