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

Cookie::__construct()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 23
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 3

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 10
nc 3
nop 8
dl 0
loc 23
rs 9.9332
c 1
b 0
f 0
ccs 14
cts 14
cp 1
crap 3

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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