Passed
Pull Request — master (#244)
by
unknown
03:18
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
 * Represent a cookie and also helps adding Set-Cookie header response in order to set 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
     * Will remove the expiration from the cookie which will convert the cookie
266
     * to session cookie, which will expire as soon as the browser is close.
267
     *
268
     * @return static
269
     */
270
    public function expireWhenBrowserIsClosed(): self
271
    {
272
        $new = clone $this;
273
        $new->expires = null;
274
        return $new;
275
    }
276
277
278
    /**
279
     * Creates a cookie copy with a new domain set.
280
     *
281
     * @param string $domain
282
     * @return static
283
     */
284
    public function withDomain(string $domain): self
285
    {
286
        $new = clone $this;
287
        $new->domain = $domain;
288
        return $new;
289
    }
290
291
    /**
292
     * Gets the domain of the cookie.
293
     *
294
     * @return string|null
295
     */
296
    public function getDomain(): ?string
297
    {
298
        return $this->domain;
299
    }
300
301
    /**
302
     * Creates a cookie copy with a new path set.
303
     *
304
     * @param string $path to be set for the cookie
305
     * @return static
306
     * @see $path for more information.
307
     */
308
    public function withPath(string $path): self
309
    {
310
        $new = clone $this;
311
        $new->setPath($path);
312
        return $new;
313
    }
314
315
    private function setPath(?string $path): void
316
    {
317
        if ($path !== null && preg_match('/[\x00-\x1F\x7F\x3B]/', $path)) {
318
            throw new InvalidArgumentException("The cookie path \"$path\" contains invalid characters.");
319
        }
320
321
        $this->path = $path;
322
    }
323
324
    /**
325
     * Gets the path of the cookie
326
     *
327
     * @return string|null
328
     */
329
    public function getPath(): ?string
330
    {
331
        return $this->path;
332
    }
333
334
    /**
335
     * Creates a cookie copy by making it secure or insecure.
336
     *
337
     * @param bool $secure whether the cookie must be secure.
338
     * @return static
339
     */
340
    public function withSecure(bool $secure = true): self
341
    {
342
        $new = clone $this;
343
        $new->secure = $secure;
344
        return $new;
345
    }
346
347
    /**
348
     * Whether the cookie is secure.
349
     *
350
     * @return bool
351
     */
352
    public function isSecure(): bool
353
    {
354
        return $this->secure ?? false;
355
    }
356
357
    /**
358
     * Creates a cookie copy that would be accessible only through the HTTP protocol.
359
     *
360
     * @param bool $httpOnly
361
     * @return static
362
     */
363
    public function withHttpOnly(bool $httpOnly = true): self
364
    {
365
        $new = clone $this;
366
        $new->httpOnly = $httpOnly;
367
        return $new;
368
    }
369
370
    /**
371
     * Whether the cookie can be accessed only through the HTTP protocol.
372
     *
373
     * @return bool
374
     */
375
    public function isHttpOnly(): bool
376
    {
377
        return $this->httpOnly ?? false;
378
    }
379
380
    /**
381
     * Creates a cookie copy with SameSite attribute.
382
     *
383
     * @param string $sameSite
384
     * @return static
385
     */
386
    public function withSameSite(string $sameSite): self
387
    {
388
        $new = clone $this;
389
        $new->setSameSite($sameSite);
390
        return $new;
391
    }
392
393
    private function setSameSite(?string $sameSite): void
394
    {
395
        if ($sameSite !== null
396
            && !in_array($sameSite, [self::SAME_SITE_LAX, self::SAME_SITE_STRICT, self::SAME_SITE_NONE], true)) {
397
            throw new InvalidArgumentException('sameSite should be one of "Lax", "Strict" or "None"');
398
        }
399
400
        if ($sameSite === self::SAME_SITE_NONE) {
401
            // the secure flag is required for cookies that are marked as 'SameSite=None'
402
            // so that cross-site cookies can only be accessed over HTTPS
403
            // without it cookie will not be available for external access
404
            $this->secure = true;
405
        }
406
407
        $this->sameSite = $sameSite;
408
    }
409
410
    /**
411
     * Gets the SameSite attribute
412
     *
413
     * @return string|null
414
     */
415
    public function getSameSite(): ?string
416
    {
417
        return $this->sameSite;
418
    }
419
420
    /**
421
     * Adds the cookie to the response and returns it.
422
     *
423
     * @param ResponseInterface $response
424
     * @return ResponseInterface response with added cookie.
425
     */
426
    public function addToResponse(ResponseInterface $response): ResponseInterface
427
    {
428
        return $response->withAddedHeader('Set-Cookie', (string) $this);
429
    }
430
431
    /**
432
     * Returns the cookie as a string.
433
     *
434
     * @return string The cookie
435
     */
436
    public function __toString(): string
437
    {
438
        $cookieParts = [
439
            $this->name . '=' . $this->value
440
        ];
441
442
        if ($this->expires !== null) {
443
            $cookieParts[] = 'Expires=' . $this->expires->format(DateTimeInterface::RFC7231);
444
            $cookieParts[] = 'Max-Age=' . ($this->expires->getTimestamp() - time());
445
        }
446
447
        if ($this->domain !== null) {
448
            $cookieParts[] = 'Domain=' . $this->domain;
449
        }
450
451
        if ($this->path !== null) {
452
            $cookieParts[] = 'Path=' . $this->path;
453
        }
454
455
        if ($this->secure) {
456
            $cookieParts[] = 'Secure';
457
        }
458
459
        if ($this->httpOnly) {
460
            $cookieParts[] = 'HttpOnly';
461
        }
462
463
        if ($this->sameSite !== null) {
464
            $cookieParts[] = 'SameSite=' . $this->sameSite;
465
        }
466
467
        return implode('; ', $cookieParts);
468
    }
469
470
    /**
471
     * Parse `Set-Cookie` string and build Cookie object.
472
     *
473
     * @param string $string `Set-Cookie` header value to parse
474
     * @return static
475
     * @throws Exception
476
     */
477
    public static function fromCookieString(string $string): self
478
    {
479
        $rawAttributes = preg_split('~\s*[;]\s*~', $string);
480
        if ($rawAttributes === false) {
481
            throw new InvalidArgumentException('Failed to parse Set-Cookie string ' . $string);
482
        }
483
        // array_filter with empty callback is used to filter out all falsy values
484
        $rawAttributes = array_filter($rawAttributes);
485
486
        $rawAttribute = array_shift($rawAttributes);
487
488
        if (!is_string($rawAttribute)) {
489
            throw new InvalidArgumentException('Cookie string must have at least name');
490
        }
491
492
        [$cookieName, $cookieValue] = self::splitCookieAttribute($rawAttribute);
493
494
        $params = [
495
            'name' => $cookieName,
496
            'value' => $cookieValue ?? '',
497
            'expires' => null,
498
            'domain' => null,
499
            'path' => null,
500
            'secure' => null,
501
            'httpOnly' => null,
502
            'sameSite' => null,
503
        ];
504
505
        while ($rawAttribute = array_shift($rawAttributes)) {
506
            [$attributeKey, $attributeValue] = self::splitCookieAttribute($rawAttribute);
507
            $attributeKey = strtolower($attributeKey);
508
509
            if ($attributeValue === null && ($attributeKey !== 'secure' || $attributeKey !== 'httponly')) {
510
                continue;
511
            }
512
513
            switch (strtolower($attributeKey)) {
514
                case 'expires':
515
                    $params['expires'] = new DateTimeImmutable($attributeValue);
516
                    break;
517
                case 'max-age':
518
                    $params['expires'] = (new DateTimeImmutable())->setTimestamp(time() + (int) $attributeValue);
519
                    break;
520
                case 'domain':
521
                    $params['domain'] = $attributeValue;
522
                    break;
523
                case 'path':
524
                    $params['path'] = $attributeValue;
525
                    break;
526
                case 'secure':
527
                    $params['secure'] = true;
528
                    break;
529
                case 'httponly':
530
                    $params['httpOnly'] = true;
531
                    break;
532
                case 'samesite':
533
                    $params['sameSite'] = $attributeValue;
534
                    break;
535
            }
536
        }
537
538
        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

538
        return new self(/** @scrutinizer ignore-type */ ...array_values($params));
Loading history...
539
    }
540
541
    private static function splitCookieAttribute(string $attribute): array
542
    {
543
        $parts = explode('=', $attribute, 2);
544
        $parts[1] = $parts[1] ?? null;
545
546
        return array_map('urldecode', $parts);
547
    }
548
}
549