Passed
Pull Request — 4 (#10335)
by Guy
10:01 queued 03:28
created

Session::isCookieSecure()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 3
c 1
b 0
f 0
nc 3
nop 2
dl 0
loc 6
rs 10
1
<?php
2
3
namespace SilverStripe\Control;
4
5
use BadMethodCallException;
6
use SilverStripe\Core\Config\Configurable;
7
use SilverStripe\Dev\Deprecation;
8
9
/**
10
 * Handles all manipulation of the session.
11
 *
12
 * An instance of a `Session` object can be retrieved via an `HTTPRequest` by calling the `getSession()` method.
13
 *
14
 * In order to support things like testing, the session is associated with a particular Controller.  In normal usage,
15
 * this is loaded from and saved to the regular PHP session, but for things like static-page-generation and
16
 * unit-testing, you can create multiple Controllers, each with their own session.
17
 *
18
 * <b>Saving Data</b>
19
 *
20
 * Once you've retrieved a session instance, you can write a value to a users session using the function {@link Session::set()}.
21
 *
22
 * <code>
23
 *  $request->getSession()->set('MyValue', 6);
24
 * </code>
25
 *
26
 * Saves the value of "6" to the MyValue session data. You can also save arrays or serialized objects in session (but
27
 * note there may be size restrictions as to how much you can save)
28
 *
29
 * <code>
30
 *
31
 *  $session = $request->getSession();
32
 *
33
 *  // save a variable
34
 *  $var = 1;
35
 *  $session->set('MyVar', $var);
36
 *
37
 *  // saves an array
38
 *  $session->set('MyArrayOfValues', array('1', '2', '3'));
39
 *
40
 *  // saves an object (you'll have to unserialize it back)
41
 *  $object = new Object();
42
 *
43
 *  $session->set('MyObject', serialize($object));
44
 * </code>
45
 *
46
 * <b>Accessing Data</b>
47
 *
48
 * Once you have saved a value to the Session you can access it by using the {@link Session::get()} function.
49
 * Note that session data isn't persisted in PHP's own session store (via $_SESSION)
50
 * until {@link Session::save()} is called, which happens automatically at the end of a standard request
51
 * through {@link SilverStripe\Control\Middleware\SessionMiddleware}.
52
 *
53
 * The values in the comments are the values stored from the previous example.
54
 *
55
 * <code>
56
 * public function bar() {
57
 *  $session = $this->getRequest()->getSession();
58
 *  $value = $session->get('MyValue'); // $value = 6
59
 *  $var   = $session->get('MyVar'); // $var = 1
60
 *  $array = $session->get('MyArrayOfValues'); // $array = array(1,2,3)
61
 *  $object = $session->get('MyObject', unserialize($object)); // $object = Object()
62
 * }
63
 * </code>
64
 *
65
 * You can also get all the values in the session at once. This is useful for debugging.
66
 *
67
 * <code>
68
 * $session->getAll(); // returns an array of all the session values.
69
 * </code>
70
 *
71
 * <b>Clearing Data</b>
72
 *
73
 * Once you have accessed a value from the Session it doesn't automatically wipe the value from the Session, you have
74
 * to specifically remove it. To clear a value you can either delete 1 session value by the name that you saved it
75
 *
76
 * <code>
77
 * $session->clear('MyValue'); // MyValue is no longer 6.
78
 * </code>
79
 *
80
 * Or you can clear every single value in the session at once. Note SilverStripe stores some of its own session data
81
 * including form and page comment information. None of this is vital but `clearAll()` will clear everything.
82
 *
83
 * <code>
84
 *  $session->clearAll();
85
 * </code>
86
 *
87
 * @see Cookie
88
 * @see HTTPRequest
89
 */
90
class Session
91
{
92
    use Configurable;
93
94
    /**
95
     * Set session timeout in seconds.
96
     *
97
     * @var int
98
     * @config
99
     */
100
    private static $timeout = 0;
0 ignored issues
show
introduced by
The private property $timeout is not used, and could be removed.
Loading history...
101
102
    /**
103
     * @config
104
     * @var array
105
     */
106
    private static $session_ips = [];
0 ignored issues
show
introduced by
The private property $session_ips is not used, and could be removed.
Loading history...
107
108
    /**
109
     * @config
110
     * @var string
111
     */
112
    private static $cookie_domain;
0 ignored issues
show
introduced by
The private property $cookie_domain is not used, and could be removed.
Loading history...
113
114
    /**
115
     * @config
116
     * @var string
117
     */
118
    private static $cookie_path;
0 ignored issues
show
introduced by
The private property $cookie_path is not used, and could be removed.
Loading history...
119
120
    /**
121
     * @config
122
     * @var string
123
     */
124
    private static $session_store_path;
0 ignored issues
show
introduced by
The private property $session_store_path is not used, and could be removed.
Loading history...
125
126
    /**
127
     * @config
128
     * @var boolean
129
     */
130
    private static $cookie_secure = false;
0 ignored issues
show
introduced by
The private property $cookie_secure is not used, and could be removed.
Loading history...
131
132
    /**
133
     * @config
134
     * @var string
135
     */
136
    private static $cookie_name_secure = 'SECSESSID';
0 ignored issues
show
introduced by
The private property $cookie_name_secure is not used, and could be removed.
Loading history...
137
138
    /**
139
     * Must be "Strict", "Lax", or "None".
140
     * @config
141
     */
142
    private static string $cookie_samesite = 'Lax';
0 ignored issues
show
introduced by
The private property $cookie_samesite is not used, and could be removed.
Loading history...
143
144
    /**
145
     * Name of session cache limiter to use.
146
     * Defaults to '' to disable cache limiter entirely.
147
     *
148
     * @see https://secure.php.net/manual/en/function.session-cache-limiter.php
149
     * @var string|null
150
     */
151
    private static $sessionCacheLimiter = '';
0 ignored issues
show
introduced by
The private property $sessionCacheLimiter is not used, and could be removed.
Loading history...
152
153
    /**
154
     * Invalidate the session if user agent header changes between request. Defaults to true. Disabling this checks is
155
     * not recommended.
156
     * @var bool
157
     * @config
158
     */
159
    private static $strict_user_agent_check = true;
0 ignored issues
show
introduced by
The private property $strict_user_agent_check is not used, and could be removed.
Loading history...
160
161
    /**
162
     * Session data.
163
     * Will be null if session has not been started
164
     *
165
     * @var array|null
166
     */
167
    protected $data = null;
168
169
    /**
170
     * @var bool
171
     */
172
    protected $started = false;
173
174
    /**
175
     * List of keys changed. This is a nested array which represents the
176
     * keys modified in $this->data. The value of each item is either "true"
177
     * or a nested array.
178
     *
179
     * If a value is in changedData but not in data, it must be removed
180
     * from the destination during save().
181
     *
182
     * Only highest level changes are stored. E.g. changes to `Base.Sub`
183
     * and then `Base` only records `Base` as the change.
184
     *
185
     * E.g.
186
     * [
187
     *   'Base' => true,
188
     *   'Key' => [
189
     *      'Nested' => true,
190
     *   ],
191
     * ]
192
     *
193
     * @var array
194
     */
195
    protected $changedData = [];
196
197
    /**
198
     * Get user agent for this request
199
     *
200
     * @param HTTPRequest $request
201
     * @return string
202
     */
203
    protected function userAgent(HTTPRequest $request)
204
    {
205
        return $request->getHeader('User-Agent');
206
    }
207
208
    /**
209
     * Start PHP session, then create a new Session object with the given start data.
210
     *
211
     * @param array|null|Session $data Can be an array of data (such as $_SESSION) or another Session object to clone.
212
     * If null, this session is treated as unstarted.
213
     */
214
    public function __construct($data)
215
    {
216
        if ($data instanceof Session) {
217
            $data = $data->getAll();
218
        }
219
220
        $this->data = $data;
221
        $this->started = isset($data);
222
    }
223
224
    /**
225
     * Init this session instance before usage,
226
     * if a session identifier is part of the passed in request.
227
     * Otherwise, a session might be started in {@link save()}
228
     * if session data needs to be written with a new session identifier.
229
     *
230
     * @param HTTPRequest $request
231
     */
232
    public function init(HTTPRequest $request)
233
    {
234
        if (!$this->isStarted() && $this->requestContainsSessionId($request)) {
235
            $this->start($request);
236
        }
237
238
        // Funny business detected!
239
        if (self::config()->get('strict_user_agent_check') && isset($this->data['HTTP_USER_AGENT'])) {
240
            if ($this->data['HTTP_USER_AGENT'] !== $this->userAgent($request)) {
241
                $this->clearAll();
242
                $this->restart($request);
243
            }
244
        }
245
    }
246
247
    /**
248
     * Destroy existing session and restart
249
     *
250
     * @param HTTPRequest $request
251
     */
252
    public function restart(HTTPRequest $request)
253
    {
254
        $this->destroy(true, $request);
255
        $this->start($request);
256
    }
257
258
    /**
259
     * Determine if this session has started
260
     *
261
     * @return bool
262
     */
263
    public function isStarted()
264
    {
265
        return $this->started;
266
    }
267
268
    /**
269
     * @param HTTPRequest $request
270
     * @return bool
271
     */
272
    public function requestContainsSessionId(HTTPRequest $request)
273
    {
274
        $secure = Director::is_https($request) && $this->config()->get('cookie_secure');
275
        $name = $secure ? $this->config()->get('cookie_name_secure') : session_name();
276
        return (bool)Cookie::get($name);
277
    }
278
279
    /**
280
     * Begin session, regardless if a session identifier is present in the request,
281
     * or whether any session data needs to be written.
282
     * See {@link init()} if you want to "lazy start" a session.
283
     *
284
     * @param HTTPRequest $request The request for which to start a session
285
     */
286
    public function start(HTTPRequest $request)
287
    {
288
        if ($this->isStarted()) {
289
            throw new BadMethodCallException("Session has already started");
290
        }
291
292
        $path = $this->config()->get('cookie_path');
293
        if (!$path) {
294
            $path = Director::baseURL();
295
        }
296
        $domain = $this->config()->get('cookie_domain');
297
        $session_path = $this->config()->get('session_store_path');
298
        $timeout = $this->config()->get('timeout');
299
300
        // Director::baseURL can return absolute domain names - this extracts the relevant parts
301
        // for the session otherwise we can get broken session cookies
302
        if (Director::is_absolute_url($path)) {
303
            $urlParts = parse_url($path ?? '');
304
            $path = $urlParts['path'];
305
            if (!$domain) {
306
                $domain = $urlParts['host'];
307
            }
308
        }
309
310
        // If the session cookie is already set, then the session can be read even if headers_sent() = true
311
        // This helps with edge-case such as debugging.
312
        $data = [];
313
        if (!session_id() && (!headers_sent() || $this->requestContainsSessionId($request))) {
314
            if (!headers_sent()) {
315
                $sameSite = static::config()->get('cookie_samesite');
316
                $secure = $this->isCookieSecure($sameSite, Director::is_https($request));
317
                session_set_cookie_params([
318
                    'lifetime' => $timeout ?: 0,
319
                    'path' => $path,
320
                    'domain' => $domain ?: null,
321
                    'secure' => $secure,
322
                    'httponly' => true,
323
                    'samesite' => $sameSite,
324
                ]);
325
326
                $limiter = $this->config()->get('sessionCacheLimiter');
327
                if (isset($limiter)) {
328
                    session_cache_limiter($limiter);
329
                }
330
331
                // Allow storing the session in a non standard location
332
                if ($session_path) {
333
                    session_save_path($session_path);
334
                }
335
336
                // If we want a secure cookie for HTTPS, use a separate session name. This lets us have a
337
                // separate (less secure) session for non-HTTPS requests
338
                // if headers_sent() is true then it's best to throw the resulting error rather than risk
339
                // a security hole.
340
                if ($secure) {
341
                    session_name($this->config()->get('cookie_name_secure'));
342
                }
343
344
                session_start();
345
346
                // Session start emits a cookie, but only if there's no existing session. If there is a session timeout
347
                // tied to this request, make sure the session is held for the entire timeout by refreshing the cookie age.
348
                if ($timeout && $this->requestContainsSessionId($request)) {
349
                    Cookie::set(session_name(), session_id(), $timeout / 86400, $path, $domain ?: null, $secure, true);
350
                }
351
            } else {
352
                // If headers are sent then we can't have a session_cache_limiter otherwise we'll get a warning
353
                session_cache_limiter(null);
354
            }
355
356
            if (isset($_SESSION)) {
357
                // Initialise data from session store if present
358
                $data = $_SESSION;
359
360
                // Merge in existing in-memory data, taking priority over session store data
361
                $this->recursivelyApply((array)$this->data, $data);
0 ignored issues
show
Deprecated Code introduced by
The function SilverStripe\Control\Session::recursivelyApply() has been deprecated: 4.1.0:5.0.0 Use recursivelyApplyChanges() instead ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

361
                /** @scrutinizer ignore-deprecated */ $this->recursivelyApply((array)$this->data, $data);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
362
            }
363
        }
364
365
        // Save any modified session data back to the session store if present, otherwise initialise it to an array.
366
        $this->data = $data;
367
368
        $this->started = true;
369
    }
370
371
    /**
372
     * Determines what the value for the `secure` cookie attribute should be.
373
     */
374
    private function isCookieSecure(string $sameSite, bool $isHttps): bool
375
    {
376
        if ($sameSite === 'None') {
377
            return true;
378
        }
379
        return $isHttps && $this->config()->get('cookie_secure');
380
    }
381
382
    /**
383
     * Destroy this session
384
     *
385
     * @param bool $removeCookie
386
     * @param HTTPRequest $request The request for which to destroy a session
387
     */
388
    public function destroy($removeCookie = true, HTTPRequest $request = null)
389
    {
390
        if (session_id()) {
391
            if ($removeCookie) {
392
                if (!$request) {
393
                    $request = Controller::curr()->getRequest();
394
                }
395
                $path = $this->config()->get('cookie_path') ?: Director::baseURL();
396
                $domain = $this->config()->get('cookie_domain');
397
                $secure = Director::is_https($request) && $this->config()->get('cookie_secure');
398
                Cookie::force_expiry(session_name(), $path, $domain, $secure, true);
399
            }
400
            session_destroy();
401
        }
402
        // Clean up the superglobal - session_destroy does not do it.
403
        // http://nz1.php.net/manual/en/function.session-destroy.php
404
        unset($_SESSION);
405
        $this->data = null;
406
        $this->started = false;
407
    }
408
409
    /**
410
     * Set session value
411
     *
412
     * @param string $name
413
     * @param mixed $val
414
     * @return $this
415
     */
416
    public function set($name, $val)
417
    {
418
        $var = &$this->nestedValueRef($name, $this->data);
419
420
        // Mark changed
421
        if ($var !== $val) {
422
            $var = $val;
423
            $this->markChanged($name);
424
        }
425
        return $this;
426
    }
427
428
    /**
429
     * Mark key as changed
430
     *
431
     * @internal
432
     * @param string $name
433
     */
434
    protected function markChanged($name)
435
    {
436
        $diffVar = &$this->changedData;
437
        foreach (explode('.', $name ?? '') as $namePart) {
438
            if (!isset($diffVar[$namePart])) {
439
                $diffVar[$namePart] = [];
440
            }
441
            $diffVar = &$diffVar[$namePart];
442
443
            // Already diffed
444
            if ($diffVar === true) {
445
                return;
446
            }
447
        }
448
        // Mark changed
449
        $diffVar = true;
450
    }
451
452
    /**
453
     * Merge value with array
454
     *
455
     * @param string $name
456
     * @param mixed $val
457
     */
458
    public function addToArray($name, $val)
459
    {
460
        $names = explode('.', $name ?? '');
461
462
        // We still want to do this even if we have strict path checking for legacy code
463
        $var = &$this->data;
464
        $diffVar = &$this->changedData;
465
466
        foreach ($names as $n) {
467
            $var = &$var[$n];
468
            $diffVar = &$diffVar[$n];
469
        }
470
471
        $var[] = $val;
472
        $diffVar[sizeof($var) - 1] = $val;
473
    }
474
475
    /**
476
     * Get session value
477
     *
478
     * @param string $name
479
     * @return mixed
480
     */
481
    public function get($name)
482
    {
483
        return $this->nestedValue($name, $this->data);
484
    }
485
486
    /**
487
     * Clear session value
488
     *
489
     * @param string $name
490
     * @return $this
491
     */
492
    public function clear($name)
493
    {
494
        // Get var by path
495
        $var = $this->nestedValue($name, $this->data);
496
497
        // Unset var
498
        if ($var !== null) {
499
            // Unset parent key
500
            $parentParts = explode('.', $name ?? '');
501
            $basePart = array_pop($parentParts);
502
            if ($parentParts) {
503
                $parent = &$this->nestedValueRef(implode('.', $parentParts), $this->data);
504
                unset($parent[$basePart]);
505
            } else {
506
                unset($this->data[$name]);
507
            }
508
            $this->markChanged($name);
509
        }
510
        return $this;
511
    }
512
513
    /**
514
     * Clear all values
515
     */
516
    public function clearAll()
517
    {
518
        if ($this->data && is_array($this->data)) {
519
            foreach (array_keys($this->data ?? []) as $key) {
520
                $this->clear($key);
521
            }
522
        }
523
    }
524
525
    /**
526
     * Get all values
527
     *
528
     * @return array|null
529
     */
530
    public function getAll()
531
    {
532
        return $this->data;
533
    }
534
535
    /**
536
     * Set user agent key
537
     *
538
     * @param HTTPRequest $request
539
     */
540
    public function finalize(HTTPRequest $request)
541
    {
542
        $this->set('HTTP_USER_AGENT', $this->userAgent($request));
543
    }
544
545
    /**
546
     * Save data to session
547
     * Only save the changes, so that anyone manipulating $_SESSION directly doesn't get burned.
548
     *
549
     * @param HTTPRequest $request
550
     */
551
    public function save(HTTPRequest $request)
552
    {
553
        if ($this->changedData) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->changedData of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
554
            $this->finalize($request);
555
556
            if (!$this->isStarted()) {
557
                $this->start($request);
558
            }
559
560
            // Apply all changes recursively, implicitly writing them to the actual PHP session store.
561
            $this->recursivelyApplyChanges($this->changedData, $this->data, $_SESSION);
562
        }
563
    }
564
565
    /**
566
     * Recursively apply the changes represented in $data to $dest.
567
     * Used to update $_SESSION
568
     *
569
     * @deprecated 4.1.0:5.0.0 Use recursivelyApplyChanges() instead
570
     * @param array $data
571
     * @param array $dest
572
     */
573
    protected function recursivelyApply($data, &$dest)
574
    {
575
        Deprecation::notice('5.0', 'Use recursivelyApplyChanges() instead');
576
        foreach ($data as $k => $v) {
577
            if (is_array($v)) {
578
                if (!isset($dest[$k]) || !is_array($dest[$k])) {
579
                    $dest[$k] = [];
580
                }
581
                $this->recursivelyApply($v, $dest[$k]);
0 ignored issues
show
Deprecated Code introduced by
The function SilverStripe\Control\Session::recursivelyApply() has been deprecated: 4.1.0:5.0.0 Use recursivelyApplyChanges() instead ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

581
                /** @scrutinizer ignore-deprecated */ $this->recursivelyApply($v, $dest[$k]);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
582
            } else {
583
                $dest[$k] = $v;
584
            }
585
        }
586
    }
587
588
    /**
589
     * Returns the list of changed keys
590
     *
591
     * @return array
592
     */
593
    public function changedData()
594
    {
595
        return $this->changedData;
596
    }
597
598
    /**
599
     * Navigate to nested value in source array by name,
600
     * creating a null placeholder if it doesn't exist.
601
     *
602
     * @internal
603
     * @param string $name
604
     * @param array $source
605
     * @return mixed Reference to value in $source
606
     */
607
    protected function &nestedValueRef($name, &$source)
608
    {
609
        // Find var to change
610
        $var = &$source;
611
        foreach (explode('.', $name ?? '') as $namePart) {
612
            if (!isset($var)) {
613
                $var = [];
614
            }
615
            if (!isset($var[$namePart])) {
616
                $var[$namePart] = null;
617
            }
618
            $var = &$var[$namePart];
619
        }
620
        return $var;
621
    }
622
623
    /**
624
     * Navigate to nested value in source array by name,
625
     * returning null if it doesn't exist.
626
     *
627
     * @internal
628
     * @param string $name
629
     * @param array $source
630
     * @return mixed Value in array in $source
631
     */
632
    protected function nestedValue($name, $source)
633
    {
634
        // Find var to change
635
        $var = $source;
636
        foreach (explode('.', $name ?? '') as $namePart) {
637
            if (!isset($var[$namePart])) {
638
                return null;
639
            }
640
            $var = $var[$namePart];
641
        }
642
        return $var;
643
    }
644
645
    /**
646
     * Apply all changes using separate keys and data sources and a destination
647
     *
648
     * @internal
649
     * @param array $changes
650
     * @param array $source
651
     * @param array $destination
652
     */
653
    protected function recursivelyApplyChanges($changes, $source, &$destination)
654
    {
655
        $source = $source ?: [];
656
        foreach ($changes as $key => $changed) {
657
            if ($changed === true) {
658
                // Determine if replacement or removal
659
                if (array_key_exists($key, $source ?? [])) {
660
                    $destination[$key] = $source[$key];
661
                } else {
662
                    unset($destination[$key]);
663
                }
664
            } else {
665
                // Recursively apply
666
                $destVal = &$this->nestedValueRef($key, $destination);
667
                $sourceVal = $this->nestedValue($key, $source);
668
                $this->recursivelyApplyChanges($changed, $sourceVal, $destVal);
669
            }
670
        }
671
    }
672
673
    /**
674
     * Regenerate session id
675
     *
676
     * @internal This is for internal use only. Isn't a part of public API.
677
     */
678
    public function regenerateSessionId()
679
    {
680
        if (!headers_sent() && session_status() === PHP_SESSION_ACTIVE) {
681
            session_regenerate_id(true);
682
        }
683
    }
684
}
685