Passed
Pull Request — master (#244)
by Alexander
02:43
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 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 10
nc 3
nop 8
dl 0
loc 22
rs 9.9332
c 1
b 0
f 0
ccs 13
cts 13
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_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