Passed
Pull Request — master (#244)
by
unknown
02:13
created

Cookie::__construct()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 22
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 3

Importance

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

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
        if (!preg_match(self::PATTERN_TOKEN, $name)) {
154 9
            throw new InvalidArgumentException("The cookie name \"$name\" contains invalid characters or is empty.");
155
        }
156 9
157
        $this->name = $name;
158 9
        $this->setValue($value);
159 2
        $this->expires = $expires !== null ? clone $expires : null;
160
        $this->domain = $domain;
161 9
        $this->setPath($path);
162 9
        $this->secure = $secure;
163
        $this->httpOnly = $httpOnly;
164 9
        $this->setSameSite($sameSite);
165 2
    }
166
167 9
    /**
168 8
     * Gets the name of the cookie.
169
     *
170 9
     * @return string
171 8
     */
172
    public function getName(): string
173 9
    {
174 8
        return $this->name;
175
    }
176
177 9
    /**
178
     * Creates a cookie copy with a new value.
179
     *
180
     * @param $value string value of the cookie.
181
     * @return static
182
     * @see $value for more information.
183
     */
184
    public function withValue(string $value): self
185
    {
186
        $new = clone $this;
187
        $new->setValue($value);
188
        return $new;
189
    }
190
191
    /**
192
     * Gets the value of the cookie.
193
     *
194
     * @return string
195
     */
196
    public function getValue(): string
197
    {
198
        return $this->value;
199
    }
200
201
    private function setValue(string $value): void
202
    {
203
        // @link https://tools.ietf.org/html/rfc6265#section-4.1.1
204
        if (!preg_match(self::PATTERN_OCTET, $value)) {
205
            throw new InvalidArgumentException("The cookie value \"$value\" contains invalid characters.");
206
        }
207
208
        $this->value = $value;
209
    }
210
211
    /**
212
     * Creates a cookie copy with a new time the cookie expires.
213
     *
214
     * @param DateTimeInterface $dateTime
215
     * @return static
216
     * @see $expires for more inforemation.
217
     */
218
    public function withExpires(DateTimeInterface $dateTime): self
219
    {
220
        $new = clone $this;
221
        // Defensively clone the object to prevent further change
222
        $new->expires = clone $dateTime;
223
        return $new;
224
    }
225
226
    /**
227
     * Gets the expiry of the cookie.
228
     *
229
     * @return DateTimeImmutable|null
230
     */
231
    public function getExpires(): ?DateTimeImmutable
232
    {
233
        if ($this->expires === null) {
234
            return null;
235
        }
236
237
        // Can be replaced with DateTimeImmutable::createFromInterface in PHP 8
238
        // returns null on `setTimestamp()` failure
239
        return (new DateTimeImmutable())->setTimestamp($this->expires->getTimestamp()) ?: null;
240
    }
241
242
    /**
243
     * Indicates whether the cookie is expired.
244
     * The cookie is expired when it has outdated `Expires`, or
245
     * zero or negative `Max-Age` attributes
246
     *
247
     * @return bool whether the cookie is expired
248
     */
249
    public function isExpired(): bool
250
    {
251
        return $this->expires !== null && $this->expires->getTimestamp() < time();
252
    }
253
254
    /**
255
     * Creates a cookie copy with a new lifetime set.
256
     * If zero or negative interval is passed, the cookie will expire immediately.
257
     *
258
     * @param DateInterval $interval interval until the cookie expires.
259
     * @return static
260
     */
261
    public function withMaxAge(DateInterval $interval): self
262
    {
263
        $new = clone $this;
264
        $new->expires = (new DateTimeImmutable())->add($interval);
265
        return $new;
266
    }
267
268
    /**
269
     * Returns modified cookie that will expire immediately.
270
     * @return static
271
     */
272
    public function expire(): self
273
    {
274
        $new = clone $this;
275
        $new->expires = new DateTimeImmutable('-1 year');
276
        return $new;
277
    }
278
279
    /**
280
     * Will remove the expiration from the cookie which will convert the cookie
281
     * to session cookie, which will expire as soon as the browser is closed.
282
     *
283
     * @return static
284
     */
285
    public function expireWhenBrowserIsClosed(): self
286
    {
287
        $new = clone $this;
288
        $new->expires = null;
289
        return $new;
290
    }
291
292
293
    /**
294
     * Creates a cookie copy with a new domain set.
295
     *
296
     * @param string $domain
297
     * @return static
298
     */
299
    public function withDomain(string $domain): self
300
    {
301
        $new = clone $this;
302
        $new->domain = $domain;
303
        return $new;
304
    }
305
306
    /**
307
     * Gets the domain of the cookie.
308
     *
309
     * @return string|null
310
     */
311
    public function getDomain(): ?string
312
    {
313
        return $this->domain;
314
    }
315
316
    /**
317
     * Creates a cookie copy with a new path set.
318
     *
319
     * @param string $path to be set for the cookie
320
     * @return static
321
     * @see $path for more information.
322
     */
323
    public function withPath(string $path): self
324
    {
325
        $new = clone $this;
326
        $new->setPath($path);
327
        return $new;
328
    }
329
330
    private function setPath(?string $path): void
331
    {
332
        if ($path !== null && preg_match('/[\x00-\x1F\x7F\x3B]/', $path)) {
333
            throw new InvalidArgumentException("The cookie path \"$path\" contains invalid characters.");
334
        }
335
336
        $this->path = $path;
337
    }
338
339
    /**
340
     * Gets the path of the cookie
341
     *
342
     * @return string|null
343
     */
344
    public function getPath(): ?string
345
    {
346
        return $this->path;
347
    }
348
349
    /**
350
     * Creates a cookie copy by making it secure or insecure.
351
     *
352
     * @param bool $secure whether the cookie must be secure.
353
     * @return static
354
     */
355
    public function withSecure(bool $secure = true): self
356
    {
357
        $new = clone $this;
358
        $new->secure = $secure;
359
        return $new;
360
    }
361
362
    /**
363
     * Whether the cookie is secure.
364
     *
365
     * @return bool
366
     */
367
    public function isSecure(): bool
368
    {
369
        return $this->secure ?? false;
370
    }
371
372
    /**
373
     * Creates a cookie copy that would be accessible only through the HTTP protocol.
374
     *
375
     * @param bool $httpOnly
376
     * @return static
377
     */
378
    public function withHttpOnly(bool $httpOnly = true): self
379
    {
380
        $new = clone $this;
381
        $new->httpOnly = $httpOnly;
382
        return $new;
383
    }
384
385
    /**
386
     * Whether the cookie can be accessed only through the HTTP protocol.
387
     *
388
     * @return bool
389
     */
390
    public function isHttpOnly(): bool
391
    {
392
        return $this->httpOnly ?? false;
393
    }
394
395
    /**
396
     * Creates a cookie copy with SameSite attribute.
397
     *
398
     * @param string $sameSite
399
     * @return static
400
     */
401
    public function withSameSite(string $sameSite): self
402
    {
403
        $new = clone $this;
404
        $new->setSameSite($sameSite);
405
        return $new;
406
    }
407
408
    private function setSameSite(?string $sameSite): void
409
    {
410
        if ($sameSite !== null
411
            && !in_array($sameSite, [self::SAME_SITE_LAX, self::SAME_SITE_STRICT, self::SAME_SITE_NONE], true)) {
412
            throw new InvalidArgumentException('sameSite should be one of "Lax", "Strict" or "None"');
413
        }
414
415
        if ($sameSite === self::SAME_SITE_NONE) {
416
            // the secure flag is required for cookies that are marked as 'SameSite=None'
417
            // so that cross-site cookies can only be accessed over HTTPS
418
            // without it cookie will not be available for external access
419
            $this->secure = true;
420
        }
421
422
        $this->sameSite = $sameSite;
423
    }
424
425
    /**
426
     * Gets the SameSite attribute
427
     *
428
     * @return string|null
429
     */
430
    public function getSameSite(): ?string
431
    {
432
        return $this->sameSite;
433
    }
434
435
    /**
436
     * Adds the cookie to the response and returns it.
437
     *
438
     * @param ResponseInterface $response
439
     * @return ResponseInterface response with added cookie.
440
     */
441
    public function addToResponse(ResponseInterface $response): ResponseInterface
442
    {
443
        return $response->withAddedHeader('Set-Cookie', (string) $this);
444
    }
445
446
    /**
447
     * Returns the cookie as a string.
448
     *
449
     * @return string The cookie
450
     */
451
    public function __toString(): string
452
    {
453
        $cookieParts = [
454
            $this->name . '=' . $this->value
455
        ];
456
457
        if ($this->expires !== null) {
458
            $cookieParts[] = 'Expires=' . $this->expires->format(DateTimeInterface::RFC7231);
459
            $cookieParts[] = 'Max-Age=' . ($this->expires->getTimestamp() - time());
460
        }
461
462
        if ($this->domain !== null) {
463
            $cookieParts[] = 'Domain=' . $this->domain;
464
        }
465
466
        if ($this->path !== null) {
467
            $cookieParts[] = 'Path=' . $this->path;
468
        }
469
470
        if ($this->secure) {
471
            $cookieParts[] = 'Secure';
472
        }
473
474
        if ($this->httpOnly) {
475
            $cookieParts[] = 'HttpOnly';
476
        }
477
478
        if ($this->sameSite !== null) {
479
            $cookieParts[] = 'SameSite=' . $this->sameSite;
480
        }
481
482
        return implode('; ', $cookieParts);
483
    }
484
485
    /**
486
     * Parse `Set-Cookie` string and build Cookie object.
487
     *
488
     * @param string $string `Set-Cookie` header value to parse
489
     * @return static
490
     * @throws Exception
491
     */
492
    public static function fromCookieString(string $string): self
493
    {
494
        $rawAttributes = preg_split('~\s*[;]\s*~', $string);
495
        if ($rawAttributes === false) {
496
            throw new InvalidArgumentException('Failed to parse Set-Cookie string ' . $string);
497
        }
498
        // array_filter with empty callback is used to filter out all falsy values
499
        $rawAttributes = array_filter($rawAttributes);
500
501
        $rawAttribute = array_shift($rawAttributes);
502
503
        if (!is_string($rawAttribute)) {
504
            throw new InvalidArgumentException('Cookie string must have at least name');
505
        }
506
507
        [$cookieName, $cookieValue] = self::splitCookieAttribute($rawAttribute);
508
509
        $params = [
510
            'name' => $cookieName,
511
            'value' => $cookieValue !== null ? urldecode($cookieValue) : '',
512
        ];
513
514
        while ($rawAttribute = array_shift($rawAttributes)) {
515
            [$attributeKey, $attributeValue] = self::splitCookieAttribute($rawAttribute);
516
            $attributeKey = strtolower($attributeKey);
517
518
            if ($attributeValue === null && !in_array($attributeKey, ['secure', 'httponly'])) {
519
                continue;
520
            }
521
522
            switch (strtolower($attributeKey)) {
523
                case 'expires':
524
                    $params['expires'] = new DateTimeImmutable($attributeValue);
525
                    break;
526
                case 'max-age':
527
                    $params['expires'] = (new DateTimeImmutable())->setTimestamp(time() + (int) $attributeValue);
528
                    break;
529
                case 'domain':
530
                    $params['domain'] = $attributeValue;
531
                    break;
532
                case 'path':
533
                    $params['path'] = $attributeValue;
534
                    break;
535
                case 'secure':
536
                    $params['secure'] = true;
537
                    break;
538
                case 'httponly':
539
                    $params['httpOnly'] = true;
540
                    break;
541
                case 'samesite':
542
                    $params['sameSite'] = $attributeValue;
543
                    break;
544
            }
545
        }
546
547
        return new self(
548
            $params['name'],
549
            $params['value'],
550
            $params['expires'] ?? null,
551
            $params['domain'] ?? null,
552
            $params['path'] ?? null,
553
            $params['secure'] ?? null,
554
            $params['httpOnly'] ?? null,
555
            $params['sameSite'] ?? null
556
        );
557
    }
558
559
    private static function splitCookieAttribute(string $attribute): array
560
    {
561
        $parts = explode('=', $attribute, 2);
562
        $parts[1] = $parts[1] ?? null;
563
564
        return $parts;
565
    }
566
}
567