Passed
Push — 4.4 ( 3659f2...c1047f )
by
unknown
11:28 queued 04:53
created

Session::set()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 5
nc 2
nop 2
dl 0
loc 10
rs 10
c 0
b 0
f 0
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 = array();
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
     * Name of session cache limiter to use.
140
     * Defaults to '' to disable cache limiter entirely.
141
     *
142
     * @see https://secure.php.net/manual/en/function.session-cache-limiter.php
143
     * @var string|null
144
     */
145
    private static $sessionCacheLimiter = '';
0 ignored issues
show
introduced by
The private property $sessionCacheLimiter is not used, and could be removed.
Loading history...
146
147
    /**
148
     * Session data.
149
     * Will be null if session has not been started
150
     *
151
     * @var array|null
152
     */
153
    protected $data = null;
154
155
    /**
156
     * @var bool
157
     */
158
    protected $started = false;
159
160
    /**
161
     * List of keys changed. This is a nested array which represents the
162
     * keys modified in $this->data. The value of each item is either "true"
163
     * or a nested array.
164
     *
165
     * If a value is in changedData but not in data, it must be removed
166
     * from the destination during save().
167
     *
168
     * Only highest level changes are stored. E.g. changes to `Base.Sub`
169
     * and then `Base` only records `Base` as the change.
170
     *
171
     * E.g.
172
     * [
173
     *   'Base' => true,
174
     *   'Key' => [
175
     *      'Nested' => true,
176
     *   ],
177
     * ]
178
     *
179
     * @var array
180
     */
181
    protected $changedData = array();
182
183
    /**
184
     * Get user agent for this request
185
     *
186
     * @param HTTPRequest $request
187
     * @return string
188
     */
189
    protected function userAgent(HTTPRequest $request)
190
    {
191
        return $request->getHeader('User-Agent');
192
    }
193
194
    /**
195
     * Start PHP session, then create a new Session object with the given start data.
196
     *
197
     * @param array|null|Session $data Can be an array of data (such as $_SESSION) or another Session object to clone.
198
     * If null, this session is treated as unstarted.
199
     */
200
    public function __construct($data)
201
    {
202
        if ($data instanceof Session) {
203
            $data = $data->getAll();
204
        }
205
206
        $this->data = $data;
207
        $this->started = isset($data);
208
    }
209
210
    /**
211
     * Init this session instance before usage,
212
     * if a session identifier is part of the passed in request.
213
     * Otherwise, a session might be started in {@link save()}
214
     * if session data needs to be written with a new session identifier.
215
     *
216
     * @param HTTPRequest $request
217
     */
218
    public function init(HTTPRequest $request)
219
    {
220
        if (!$this->isStarted() && $this->requestContainsSessionId($request)) {
221
            $this->start($request);
222
        }
223
224
        // Funny business detected!
225
        if (isset($this->data['HTTP_USER_AGENT'])) {
226
            if ($this->data['HTTP_USER_AGENT'] !== $this->userAgent($request)) {
227
                $this->clearAll();
228
                $this->destroy();
229
                $this->started = false;
230
                $this->start($request);
231
            }
232
        }
233
    }
234
235
    /**
236
     * Destroy existing session and restart
237
     *
238
     * @param HTTPRequest $request
239
     */
240
    public function restart(HTTPRequest $request)
241
    {
242
        $this->destroy();
243
        $this->init($request);
244
    }
245
246
    /**
247
     * Determine if this session has started
248
     *
249
     * @return bool
250
     */
251
    public function isStarted()
252
    {
253
        return $this->started;
254
    }
255
256
    /**
257
     * @param HTTPRequest $request
258
     * @return bool
259
     */
260
    public function requestContainsSessionId(HTTPRequest $request)
261
    {
262
        $secure = Director::is_https($request) && $this->config()->get('cookie_secure');
263
        $name = $secure ? $this->config()->get('cookie_name_secure') : session_name();
264
        return (bool)Cookie::get($name);
265
    }
266
267
    /**
268
     * Begin session, regardless if a session identifier is present in the request,
269
     * or whether any session data needs to be written.
270
     * See {@link init()} if you want to "lazy start" a session.
271
     *
272
     * @param HTTPRequest $request The request for which to start a session
273
     */
274
    public function start(HTTPRequest $request)
275
    {
276
        if ($this->isStarted()) {
277
            throw new BadMethodCallException("Session has already started");
278
        }
279
280
        $path = $this->config()->get('cookie_path');
281
        if (!$path) {
282
            $path = Director::baseURL();
283
        }
284
        $domain = $this->config()->get('cookie_domain');
285
        $secure = Director::is_https($request) && $this->config()->get('cookie_secure');
286
        $session_path = $this->config()->get('session_store_path');
287
        $timeout = $this->config()->get('timeout');
288
289
        // Director::baseURL can return absolute domain names - this extracts the relevant parts
290
        // for the session otherwise we can get broken session cookies
291
        if (Director::is_absolute_url($path)) {
292
            $urlParts = parse_url($path);
293
            $path = $urlParts['path'];
294
            if (!$domain) {
295
                $domain = $urlParts['host'];
296
            }
297
        }
298
299
        // If the session cookie is already set, then the session can be read even if headers_sent() = true
300
        // This helps with edge-case such as debugging.
301
        $data = [];
302
        if (!session_id() && (!headers_sent() || $this->requestContainsSessionId($request))) {
303
            if (!headers_sent()) {
304
                session_set_cookie_params($timeout ?: 0, $path, $domain ?: null, $secure, true);
305
306
                $limiter = $this->config()->get('sessionCacheLimiter');
307
                if (isset($limiter)) {
308
                    session_cache_limiter($limiter);
309
                }
310
311
                // Allow storing the session in a non standard location
312
                if ($session_path) {
313
                    session_save_path($session_path);
314
                }
315
316
                // If we want a secure cookie for HTTPS, use a separate session name. This lets us have a
317
                // separate (less secure) session for non-HTTPS requests
318
                // if headers_sent() is true then it's best to throw the resulting error rather than risk
319
                // a security hole.
320
                if ($secure) {
321
                    session_name($this->config()->get('cookie_name_secure'));
322
                }
323
324
                session_start();
325
326
                // Session start emits a cookie, but only if there's no existing session. If there is a session timeout
327
                // tied to this request, make sure the session is held for the entire timeout by refreshing the cookie age.
328
                if ($timeout && $this->requestContainsSessionId($request)) {
329
                    Cookie::set(session_name(), session_id(), $timeout / 86400, $path, $domain ?: null, $secure, true);
330
                }
331
            } else {
332
                // If headers are sent then we can't have a session_cache_limiter otherwise we'll get a warning
333
                session_cache_limiter(null);
334
            }
335
336
            if (isset($_SESSION)) {
337
                // Initialise data from session store if present
338
                $data = $_SESSION;
339
340
                // Merge in existing in-memory data, taking priority over session store data
341
                $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

341
                /** @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...
342
            }
343
        }
344
345
        // Save any modified session data back to the session store if present, otherwise initialise it to an array.
346
        $this->data = $data;
347
348
        $this->started = true;
349
    }
350
351
    /**
352
     * Destroy this session
353
     *
354
     * @param bool $removeCookie
355
     */
356
    public function destroy($removeCookie = true)
357
    {
358
        if (session_id()) {
359
            if ($removeCookie) {
360
                $path = $this->config()->get('cookie_path') ?: Director::baseURL();
361
                $domain = $this->config()->get('cookie_domain');
362
                $secure = $this->config()->get('cookie_secure');
363
                Cookie::force_expiry(session_name(), $path, $domain, $secure, true);
364
            }
365
            session_destroy();
366
        }
367
        // Clean up the superglobal - session_destroy does not do it.
368
        // http://nz1.php.net/manual/en/function.session-destroy.php
369
        unset($_SESSION);
370
        $this->data = null;
371
    }
372
373
    /**
374
     * Set session value
375
     *
376
     * @param string $name
377
     * @param mixed $val
378
     * @return $this
379
     */
380
    public function set($name, $val)
381
    {
382
        $var = &$this->nestedValueRef($name, $this->data);
383
384
        // Mark changed
385
        if ($var !== $val) {
386
            $var = $val;
387
            $this->markChanged($name);
388
        }
389
        return $this;
390
    }
391
392
    /**
393
     * Mark key as changed
394
     *
395
     * @internal
396
     * @param string $name
397
     */
398
    protected function markChanged($name)
399
    {
400
        $diffVar = &$this->changedData;
401
        foreach (explode('.', $name) as $namePart) {
402
            if (!isset($diffVar[$namePart])) {
403
                $diffVar[$namePart] = [];
404
            }
405
            $diffVar = &$diffVar[$namePart];
406
407
            // Already diffed
408
            if ($diffVar === true) {
409
                return;
410
            }
411
        }
412
        // Mark changed
413
        $diffVar = true;
414
    }
415
416
    /**
417
     * Merge value with array
418
     *
419
     * @param string $name
420
     * @param mixed $val
421
     */
422
    public function addToArray($name, $val)
423
    {
424
        $names = explode('.', $name);
425
426
        // We still want to do this even if we have strict path checking for legacy code
427
        $var = &$this->data;
428
        $diffVar = &$this->changedData;
429
430
        foreach ($names as $n) {
431
            $var = &$var[$n];
432
            $diffVar = &$diffVar[$n];
433
        }
434
435
        $var[] = $val;
436
        $diffVar[sizeof($var) - 1] = $val;
437
    }
438
439
    /**
440
     * Get session value
441
     *
442
     * @param string $name
443
     * @return mixed
444
     */
445
    public function get($name)
446
    {
447
        return $this->nestedValue($name, $this->data);
448
    }
449
450
    /**
451
     * Clear session value
452
     *
453
     * @param string $name
454
     * @return $this
455
     */
456
    public function clear($name)
457
    {
458
        // Get var by path
459
        $var = $this->nestedValue($name, $this->data);
460
461
        // Unset var
462
        if ($var !== null) {
463
            // Unset parent key
464
            $parentParts = explode('.', $name);
465
            $basePart = array_pop($parentParts);
466
            if ($parentParts) {
467
                $parent = &$this->nestedValueRef(implode('.', $parentParts), $this->data);
468
                unset($parent[$basePart]);
469
            } else {
470
                unset($this->data[$name]);
471
            }
472
            $this->markChanged($name);
473
        }
474
        return $this;
475
    }
476
477
    /**
478
     * Clear all values
479
     */
480
    public function clearAll()
481
    {
482
        if ($this->data && is_array($this->data)) {
483
            foreach (array_keys($this->data) as $key) {
484
                $this->clear($key);
485
            }
486
        }
487
    }
488
489
    /**
490
     * Get all values
491
     *
492
     * @return array|null
493
     */
494
    public function getAll()
495
    {
496
        return $this->data;
497
    }
498
499
    /**
500
     * Set user agent key
501
     *
502
     * @param HTTPRequest $request
503
     */
504
    public function finalize(HTTPRequest $request)
505
    {
506
        $this->set('HTTP_USER_AGENT', $this->userAgent($request));
507
    }
508
509
    /**
510
     * Save data to session
511
     * Only save the changes, so that anyone manipulating $_SESSION directly doesn't get burned.
512
     *
513
     * @param HTTPRequest $request
514
     */
515
    public function save(HTTPRequest $request)
516
    {
517
        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...
518
            $this->finalize($request);
519
520
            if (!$this->isStarted()) {
521
                $this->start($request);
522
            }
523
524
            // Apply all changes recursively, implicitly writing them to the actual PHP session store.
525
            $this->recursivelyApplyChanges($this->changedData, $this->data, $_SESSION);
526
        }
527
    }
528
529
    /**
530
     * Recursively apply the changes represented in $data to $dest.
531
     * Used to update $_SESSION
532
     *
533
     * @deprecated 4.1.0:5.0.0 Use recursivelyApplyChanges() instead
534
     * @param array $data
535
     * @param array $dest
536
     */
537
    protected function recursivelyApply($data, &$dest)
538
    {
539
        Deprecation::notice('5.0', 'Use recursivelyApplyChanges() instead');
540
        foreach ($data as $k => $v) {
541
            if (is_array($v)) {
542
                if (!isset($dest[$k]) || !is_array($dest[$k])) {
543
                    $dest[$k] = array();
544
                }
545
                $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

545
                /** @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...
546
            } else {
547
                $dest[$k] = $v;
548
            }
549
        }
550
    }
551
552
    /**
553
     * Returns the list of changed keys
554
     *
555
     * @return array
556
     */
557
    public function changedData()
558
    {
559
        return $this->changedData;
560
    }
561
562
    /**
563
     * Navigate to nested value in source array by name,
564
     * creating a null placeholder if it doesn't exist.
565
     *
566
     * @internal
567
     * @param string $name
568
     * @param array $source
569
     * @return mixed Reference to value in $source
570
     */
571
    protected function &nestedValueRef($name, &$source)
572
    {
573
        // Find var to change
574
        $var = &$source;
575
        foreach (explode('.', $name) as $namePart) {
576
            if (!isset($var)) {
577
                $var = [];
578
            }
579
            if (!isset($var[$namePart])) {
580
                $var[$namePart] = null;
581
            }
582
            $var = &$var[$namePart];
583
        }
584
        return $var;
585
    }
586
587
    /**
588
     * Navigate to nested value in source array by name,
589
     * returning null if it doesn't exist.
590
     *
591
     * @internal
592
     * @param string $name
593
     * @param array $source
594
     * @return mixed Value in array in $source
595
     */
596
    protected function nestedValue($name, $source)
597
    {
598
        // Find var to change
599
        $var = $source;
600
        foreach (explode('.', $name) as $namePart) {
601
            if (!isset($var[$namePart])) {
602
                return null;
603
            }
604
            $var = $var[$namePart];
605
        }
606
        return $var;
607
    }
608
609
    /**
610
     * Apply all changes using separate keys and data sources and a destination
611
     *
612
     * @internal
613
     * @param array $changes
614
     * @param array $source
615
     * @param array $destination
616
     */
617
    protected function recursivelyApplyChanges($changes, $source, &$destination)
618
    {
619
        $source = $source ?: [];
620
        foreach ($changes as $key => $changed) {
621
            if ($changed === true) {
622
                // Determine if replacement or removal
623
                if (array_key_exists($key, $source)) {
624
                    $destination[$key] = $source[$key];
625
                } else {
626
                    unset($destination[$key]);
627
                }
628
            } else {
629
                // Recursively apply
630
                $destVal = &$this->nestedValueRef($key, $destination);
631
                $sourceVal = $this->nestedValue($key, $source);
632
                $this->recursivelyApplyChanges($changed, $sourceVal, $destVal);
633
            }
634
        }
635
    }
636
637
    /**
638
     * Regenerate session id
639
     *
640
     * @internal This is for internal use only. Isn't a part of public API.
641
     */
642
    public function regenerateSessionId()
643
    {
644
        if (!headers_sent()) {
645
            session_regenerate_id(true);
646
        }
647
    }
648
}
649