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

Cookie::setValue()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
cc 2
eloc 3
c 0
b 0
f 0
nc 2
nop 1
dl 0
loc 8
ccs 0
cts 0
cp 0
crap 6
rs 10
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