Completed
Push — 4 ( 6c0917...fa3556 )
by Guy
40s queued 26s
created

Session::start()   C

Complexity

Conditions 12
Paths 36

Size

Total Lines 67
Code Lines 32

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 12
eloc 32
nc 36
nop 1
dl 0
loc 67
rs 6.9666
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
        $session_path = $this->config()->get('session_store_path');
293
294
        // If the session cookie is already set, then the session can be read even if headers_sent() = true
295
        // This helps with edge-case such as debugging.
296
        $data = [];
297
        if (!session_id() && (!headers_sent() || $this->requestContainsSessionId($request))) {
298
            if (!headers_sent()) {
299
                $cookieParams = $this->buildCookieParams($request);
300
                session_set_cookie_params($cookieParams);
301
302
                $limiter = $this->config()->get('sessionCacheLimiter');
303
                if (isset($limiter)) {
304
                    session_cache_limiter($limiter);
305
                }
306
307
                // Allow storing the session in a non standard location
308
                if ($session_path) {
309
                    session_save_path($session_path);
310
                }
311
312
                // If we want a secure cookie for HTTPS, use a separate session name. This lets us have a
313
                // separate (less secure) session for non-HTTPS requests
314
                // if headers_sent() is true then it's best to throw the resulting error rather than risk
315
                // a security hole.
316
                if ($cookieParams['secure']) {
317
                    session_name($this->config()->get('cookie_name_secure'));
318
                }
319
320
                session_start();
321
322
                // Session start emits a cookie, but only if there's no existing session. If there is a session timeout
323
                // tied to this request, make sure the session is held for the entire timeout by refreshing the cookie age.
324
                if ($cookieParams['lifetime'] && $this->requestContainsSessionId($request)) {
325
                    Cookie::set(
326
                        session_name(),
327
                        session_id(),
328
                        $cookieParams['lifetime'] / 86400,
329
                        $cookieParams['path'],
330
                        $cookieParams['domain'],
331
                        $cookieParams['secure'],
332
                        true
333
                    );
334
                }
335
            } else {
336
                // If headers are sent then we can't have a session_cache_limiter otherwise we'll get a warning
337
                session_cache_limiter(null);
338
            }
339
340
            if (isset($_SESSION)) {
341
                // Initialise data from session store if present
342
                $data = $_SESSION;
343
344
                // Merge in existing in-memory data, taking priority over session store data
345
                $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

345
                /** @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...
346
            }
347
        }
348
349
        // Save any modified session data back to the session store if present, otherwise initialise it to an array.
350
        $this->data = $data;
351
352
        $this->started = true;
353
    }
354
355
    /**
356
     * Build the parameters used for setting the session cookie.
357
     */
358
    private function buildCookieParams(HTTPRequest $request): array
359
    {
360
        $timeout = $this->config()->get('timeout');
361
        $path = $this->config()->get('cookie_path');
362
        $domain = $this->config()->get('cookie_domain');
363
        if (!$path) {
364
            $path = Director::baseURL();
365
        }
366
367
        // Director::baseURL can return absolute domain names - this extracts the relevant parts
368
        // for the session otherwise we can get broken session cookies
369
        if (Director::is_absolute_url($path)) {
370
            $urlParts = parse_url($path ?? '');
371
            $path = $urlParts['path'];
372
            if (!$domain) {
373
                $domain = $urlParts['host'];
374
            }
375
        }
376
377
        $sameSite = static::config()->get('cookie_samesite');
378
        Cookie::validateSameSite($sameSite);
379
        $secure = $this->isCookieSecure($sameSite, Director::is_https($request));
380
381
        return [
382
            'lifetime' => $timeout ?: 0,
383
            'path' => $path,
384
            'domain' => $domain ?: null,
385
            'secure' => $secure,
386
            'httponly' => true,
387
            'samesite' => $sameSite,
388
        ];
389
    }
390
391
    /**
392
     * Determines what the value for the `secure` cookie attribute should be.
393
     */
394
    private function isCookieSecure(string $sameSite, bool $isHttps): bool
395
    {
396
        if ($sameSite === 'None') {
397
            return true;
398
        }
399
        return $isHttps && $this->config()->get('cookie_secure');
400
    }
401
402
    /**
403
     * Destroy this session
404
     *
405
     * @param bool $removeCookie
406
     * @param HTTPRequest $request The request for which to destroy a session
407
     */
408
    public function destroy($removeCookie = true, HTTPRequest $request = null)
409
    {
410
        if (session_id()) {
411
            if ($removeCookie) {
412
                if (!$request) {
413
                    $request = Controller::curr()->getRequest();
414
                }
415
                $path = $this->config()->get('cookie_path') ?: Director::baseURL();
416
                $domain = $this->config()->get('cookie_domain');
417
                $secure = Director::is_https($request) && $this->config()->get('cookie_secure');
418
                Cookie::force_expiry(session_name(), $path, $domain, $secure, true);
419
            }
420
            session_destroy();
421
        }
422
        // Clean up the superglobal - session_destroy does not do it.
423
        // http://nz1.php.net/manual/en/function.session-destroy.php
424
        unset($_SESSION);
425
        $this->data = null;
426
        $this->started = false;
427
    }
428
429
    /**
430
     * Set session value
431
     *
432
     * @param string $name
433
     * @param mixed $val
434
     * @return $this
435
     */
436
    public function set($name, $val)
437
    {
438
        $var = &$this->nestedValueRef($name, $this->data);
439
440
        // Mark changed
441
        if ($var !== $val) {
442
            $var = $val;
443
            $this->markChanged($name);
444
        }
445
        return $this;
446
    }
447
448
    /**
449
     * Mark key as changed
450
     *
451
     * @internal
452
     * @param string $name
453
     */
454
    protected function markChanged($name)
455
    {
456
        $diffVar = &$this->changedData;
457
        foreach (explode('.', $name ?? '') as $namePart) {
458
            if (!isset($diffVar[$namePart])) {
459
                $diffVar[$namePart] = [];
460
            }
461
            $diffVar = &$diffVar[$namePart];
462
463
            // Already diffed
464
            if ($diffVar === true) {
465
                return;
466
            }
467
        }
468
        // Mark changed
469
        $diffVar = true;
470
    }
471
472
    /**
473
     * Merge value with array
474
     *
475
     * @param string $name
476
     * @param mixed $val
477
     */
478
    public function addToArray($name, $val)
479
    {
480
        $names = explode('.', $name ?? '');
481
482
        // We still want to do this even if we have strict path checking for legacy code
483
        $var = &$this->data;
484
        $diffVar = &$this->changedData;
485
486
        foreach ($names as $n) {
487
            $var = &$var[$n];
488
            $diffVar = &$diffVar[$n];
489
        }
490
491
        $var[] = $val;
492
        $diffVar[sizeof($var) - 1] = $val;
493
    }
494
495
    /**
496
     * Get session value
497
     *
498
     * @param string $name
499
     * @return mixed
500
     */
501
    public function get($name)
502
    {
503
        return $this->nestedValue($name, $this->data);
504
    }
505
506
    /**
507
     * Clear session value
508
     *
509
     * @param string $name
510
     * @return $this
511
     */
512
    public function clear($name)
513
    {
514
        // Get var by path
515
        $var = $this->nestedValue($name, $this->data);
516
517
        // Unset var
518
        if ($var !== null) {
519
            // Unset parent key
520
            $parentParts = explode('.', $name ?? '');
521
            $basePart = array_pop($parentParts);
522
            if ($parentParts) {
523
                $parent = &$this->nestedValueRef(implode('.', $parentParts), $this->data);
524
                unset($parent[$basePart]);
525
            } else {
526
                unset($this->data[$name]);
527
            }
528
            $this->markChanged($name);
529
        }
530
        return $this;
531
    }
532
533
    /**
534
     * Clear all values
535
     */
536
    public function clearAll()
537
    {
538
        if ($this->data && is_array($this->data)) {
539
            foreach (array_keys($this->data ?? []) as $key) {
540
                $this->clear($key);
541
            }
542
        }
543
    }
544
545
    /**
546
     * Get all values
547
     *
548
     * @return array|null
549
     */
550
    public function getAll()
551
    {
552
        return $this->data;
553
    }
554
555
    /**
556
     * Set user agent key
557
     *
558
     * @param HTTPRequest $request
559
     */
560
    public function finalize(HTTPRequest $request)
561
    {
562
        $this->set('HTTP_USER_AGENT', $this->userAgent($request));
563
    }
564
565
    /**
566
     * Save data to session
567
     * Only save the changes, so that anyone manipulating $_SESSION directly doesn't get burned.
568
     *
569
     * @param HTTPRequest $request
570
     */
571
    public function save(HTTPRequest $request)
572
    {
573
        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...
574
            $this->finalize($request);
575
576
            if (!$this->isStarted()) {
577
                $this->start($request);
578
            }
579
580
            // Apply all changes recursively, implicitly writing them to the actual PHP session store.
581
            $this->recursivelyApplyChanges($this->changedData, $this->data, $_SESSION);
582
        }
583
    }
584
585
    /**
586
     * Recursively apply the changes represented in $data to $dest.
587
     * Used to update $_SESSION
588
     *
589
     * @deprecated 4.1.0:5.0.0 Use recursivelyApplyChanges() instead
590
     * @param array $data
591
     * @param array $dest
592
     */
593
    protected function recursivelyApply($data, &$dest)
594
    {
595
        Deprecation::notice('5.0', 'Use recursivelyApplyChanges() instead');
596
        foreach ($data as $k => $v) {
597
            if (is_array($v)) {
598
                if (!isset($dest[$k]) || !is_array($dest[$k])) {
599
                    $dest[$k] = [];
600
                }
601
                $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

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