Completed
Push — master ( 72d3e7...47dd29 )
by Nazar
11:08
created

Session::construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 5
rs 9.4285
cc 1
eloc 4
nc 1
nop 0
1
<?php
2
/**
3
 * @package   CleverStyle CMS
4
 * @author    Nazar Mokrynskyi <[email protected]>
5
 * @copyright Copyright (c) 2011-2016, Nazar Mokrynskyi
6
 * @license   MIT License, see license.txt
7
 */
8
/**
9
 * Provides next events:
10
 *
11
 *  System/Session/init/before
12
 *
13
 *  System/Session/init/after
14
 *
15
 *  System/Session/load
16
 *  ['session_data' => $session_data]
17
 *
18
 *  System/Session/add
19
 *  ['session_data' => $session_data]
20
 *
21
 *  System/Session/del/before
22
 *  ['id' => $session_id]
23
 *
24
 *  System/Session/del/after
25
 *  ['id' => $session_id]
26
 *
27
 *  System/Session/del_all
28
 *  ['id' => $user_id]
29
 */
30
namespace cs;
31
use
32
	cs\Cache\Prefix;
33
34
/**
35
 * Class responsible for current user session
36
 *
37
 * @method static Session instance($check = false)
38
 */
39
class Session {
40
	use
41
		CRUD,
42
		Singleton;
43
	const INITIAL_SESSION_EXPIRATION = 300;
44
	/**
45
	 * Id of current session
46
	 *
47
	 * @var false|string
48
	 */
49
	protected $session_id;
50
	/**
51
	 * User id of current session
52
	 *
53
	 * @var int
54
	 */
55
	protected $user_id  = User::GUEST_ID;
56
	protected $is_admin = false;
57
	protected $is_user  = false;
58
	protected $is_bot   = false;
59
	protected $is_guest = false;
60
	/**
61
	 * @var Prefix
62
	 */
63
	protected $cache;
64
	/**
65
	 * @var Prefix
66
	 */
67
	protected $users_cache;
68
	protected $data_model = [
69
		'id'          => 'text',
70
		'user'        => 'int:0',
71
		'created'     => 'int:0',
72
		'expire'      => 'int:0',
73
		'user_agent'  => 'text',
74
		'remote_addr' => 'text',
75
		'ip'          => 'text',
76
		'data'        => 'json'
77
	];
78
	protected $table      = '[prefix]sessions';
79
	protected function construct () {
80
		$this->cache       = new Prefix('sessions');
81
		$this->users_cache = new Prefix('users');
82
		$this->initialize();
83
	}
84
	/**
85
	 * Returns database index
86
	 *
87
	 * @return int
88
	 */
89
	protected function cdb () {
90
		return Config::instance()->module('System')->db('users');
91
	}
92
	/**
93
	 * Use cookie as source of session id, load session
94
	 *
95
	 * Bots detection is also done here
96
	 */
97
	protected function initialize () {
98
		Event::instance()->fire('System/Session/init/before');
99
		/**
100
		 * If session exists
101
		 */
102
		if (_getcookie('session')) {
103
			$this->user_id = $this->load();
104
		} elseif (!api_path()) {
105
			/**
106
			 * Try to detect bot, not necessary for API request
107
			 */
108
			$this->bots_detection();
109
		}
110
		$this->update_user_is();
111
		Event::instance()->fire('System/Session/init/after');
112
	}
113
	/**
114
	 * Try to determine whether visitor is a known bot, bots have no sessions
115
	 */
116
	protected function bots_detection () {
117
		$Cache = $this->users_cache;
118
		/**
119
		 * @var \cs\_SERVER $_SERVER
120
		 */
121
		/**
122
		 * For bots: login is user agent, email is IP
123
		 */
124
		$login    = $_SERVER->user_agent;
125
		$email    = $_SERVER->ip;
126
		$bot_hash = hash('sha224', $login.$email);
127
		/**
128
		 * If bot is cached
129
		 */
130
		$bot_id = $Cache->$bot_hash;
131
		/**
132
		 * If bot found in cache - exit from here
133
		 */
134
		if ($bot_id) {
135
			$this->user_id = $bot_id;
136
			return;
137
		}
138
		/**
139
		 * Try to find bot among known bots
140
		 */
141
		foreach ($this->all_bots() as $bot) {
142
			if ($this->is_this_bot($bot, $login, $email)) {
143
				/**
144
				 * If bot found - save it in cache
145
				 */
146
				$this->user_id    = $bot['id'];
147
				$Cache->$bot_hash = $bot['id'];
148
				return;
149
			}
150
		}
151
	}
152
	/**
153
	 * Get list of all bots
154
	 *
155
	 * @return array
156
	 */
157
	protected function all_bots () {
158
		return $this->users_cache->get(
159
			'bots',
160
			function () {
161
				return $this->db()->qfa(
162
					[
163
						"SELECT
164
							`u`.`id`,
165
							`u`.`login`,
166
							`u`.`email`
167
						FROM `[prefix]users` AS `u`
168
							INNER JOIN `[prefix]users_groups` AS `g`
169
						ON `u`.`id` = `g`.`id`
170
						WHERE
171
							`g`.`group`		= '%s' AND
172
							`u`.`status`	= '%s'",
173
						User::BOT_GROUP_ID,
174
						User::STATUS_ACTIVE
175
					]
176
				) ?: [];
177
			}
178
		) ?: [];
179
	}
180
	/**
181
	 * Check whether user agent and IP (login and email for bots) corresponds to passed bot data
182
	 *
183
	 * @param array  $bot
184
	 * @param string $login
185
	 * @param string $email
186
	 *
187
	 * @return bool
188
	 */
189
	protected function is_this_bot ($bot, $login, $email) {
190
		return
191
			(
192
				$bot['login'] &&
193
				(
194
					strpos($login, $bot['login']) !== false ||
195
					_preg_match($bot['login'], $login)
196
				)
197
			) ||
198
			(
199
				$bot['email'] &&
200
				(
201
					$email === $bot['email'] ||
202
					_preg_match($bot['email'], $email)
203
				)
204
			);
205
	}
206
	/**
207
	 * Updates information about who is user accessed by methods ::guest() ::bot() ::user() admin()
208
	 */
209
	protected function update_user_is () {
210
		$this->is_guest = false;
211
		$this->is_bot   = false;
212
		$this->is_user  = false;
213
		$this->is_admin = false;
214
		if ($this->user_id == User::GUEST_ID) {
215
			$this->is_guest = true;
216
			return;
217
		}
218
		/**
219
		 * Checking of user type
220
		 */
221
		$groups = User::instance()->get_groups($this->user_id) ?: [];
222
		if (in_array(User::ADMIN_GROUP_ID, $groups)) {
223
			$this->is_admin = true;
224
			$this->is_user  = true;
225
		} elseif (in_array(User::USER_GROUP_ID, $groups)) {
226
			$this->is_user = true;
227
		} elseif (in_array(User::BOT_GROUP_ID, $groups)) {
228
			$this->is_guest = true;
229
			$this->is_bot   = true;
230
		}
231
	}
232
	/**
233
	 * Is admin
234
	 *
235
	 * @return bool
236
	 */
237
	function admin () {
238
		return $this->is_admin;
239
	}
240
	/**
241
	 * Is user
242
	 *
243
	 * @return bool
244
	 */
245
	function user () {
246
		return $this->is_user;
247
	}
248
	/**
249
	 * Is guest
250
	 *
251
	 * @return bool
252
	 */
253
	function guest () {
254
		return $this->is_guest;
255
	}
256
	/**
257
	 * Is bot
258
	 *
259
	 * @return bool
260
	 */
261
	function bot () {
262
		return $this->is_bot;
263
	}
264
	/**
265
	 * Returns id of current session
266
	 *
267
	 * @return false|string
268
	 */
269
	function get_id () {
270
		if ($this->user_id == User::GUEST_ID && $this->bot()) {
271
			return false;
272
		}
273
		return $this->session_id ?: false;
274
	}
275
	/**
276
	 * Returns user id of current session
277
	 *
278
	 * @return int
279
	 */
280
	function get_user () {
281
		return $this->user_id;
282
	}
283
	/**
284
	 * Returns session details by session id
285
	 *
286
	 * @param false|null|string $session_id If `null` - loaded from `$this->session_id`, and if that also empty - from cookies
287
	 *
288
	 * @return false|array
289
	 */
290
	function get ($session_id) {
291
		$session_data = $this->get_internal($session_id);
292
		if ($session_data) {
293
			unset($session_data['data']);
294
		}
295
		return $session_data;
296
	}
297
	/**
298
	 * @param false|null|string $session_id
299
	 *
300
	 * @return false|array
301
	 */
302
	protected function get_internal ($session_id) {
303
		if (!$session_id) {
304
			if (!$this->session_id) {
305
				$this->session_id = _getcookie('session');
306
			}
307
			$session_id = $this->session_id;
308
		}
309
		if (!is_md5($session_id)) {
310
			return false;
311
		}
312
		$data = $this->cache->get(
313
			$session_id,
314
			function () use ($session_id) {
315
				$data = $this->read($session_id);
316
				if (!$data || $data['expire'] <= time()) {
317
					return false;
318
				}
319
				$data['data'] = $data['data'] ?: [];
320
				return $data;
321
			}
322
		);
323
		return $this->is_good_session($data) ? $data : false;
324
	}
325
	/**
326
	 * Check whether session was not expired, user agent and IP corresponds to what is expected and user is actually active
327
	 *
328
	 * @param mixed $session_data
329
	 *
330
	 * @return bool
331
	 */
332
	protected function is_good_session ($session_data) {
333
		return
334
			isset($session_data['expire'], $session_data['user']) &&
335
			$session_data['expire'] > time() &&
336
			$this->is_user_active($session_data['user']);
337
	}
338
	/**
339
	 * Whether session data belongs to current visitor (user agent, remote addr and ip check)
340
	 *
341
	 * @param string $session_id
342
	 * @param string $user_agent
343
	 * @param string $remote_addr
344
	 * @param string $ip
345
	 *
346
	 * @return bool
347
	 */
348
	function is_session_owner ($session_id, $user_agent, $remote_addr, $ip) {
349
		$session_data = $this->get($session_id);
350
		return $session_data ? $this->is_session_owner_internal($session_data, $user_agent, $remote_addr, $ip) : false;
351
	}
352
	/**
353
	 * Whether session data belongs to current visitor (user agent, remote addr and ip check)
354
	 *
355
	 * @param array       $session_data
356
	 * @param string|null $user_agent
357
	 * @param string|null $remote_addr
358
	 * @param string|null $ip
359
	 *
360
	 * @return bool
361
	 */
362
	protected function is_session_owner_internal ($session_data, $user_agent = null, $remote_addr = null, $ip = null) {
363
		/**
364
		 * md5() as protection against timing attacks
365
		 *
366
		 * @var \cs\_SERVER $_SERVER
367
		 */
368
		if ($user_agent === null && $remote_addr === null && $ip === null) {
369
			$user_agent  = $_SERVER->user_agent;
370
			$remote_addr = $_SERVER->remote_addr;
371
			$ip          = $_SERVER->ip;
372
		}
373
		return
374
			md5($session_data['user_agent']) == md5($user_agent) &&
375
			(
376
				!Config::instance()->core['remember_user_ip'] ||
377
				(
378
					md5($session_data['remote_addr']) == md5(ip2hex($remote_addr)) &&
379
					md5($session_data['ip']) == md5(ip2hex($ip))
380
				)
381
			);
382
	}
383
	/**
384
	 * Load session by id and return id of session owner (user), update session expiration
385
	 *
386
	 * @param false|null|string $session_id If not specified - loaded from `$this->session_id`, and if that also empty - from cookies
387
	 *
388
	 * @return int User id
389
	 */
390
	function load ($session_id = null) {
391
		if ($this->user_id == User::GUEST_ID && $this->bot()) {
392
			return User::GUEST_ID;
393
		}
394
		$session_data = $this->get_internal($session_id);
395
		if (!$session_data || !$this->is_session_owner_internal($session_data)) {
396
			$this->add(User::GUEST_ID);
397
			return User::GUEST_ID;
398
		}
399
		/**
400
		 * Updating last online time and ip
401
		 */
402
		$Config = Config::instance();
403
		$time   = time();
404
		if ($session_data['expire'] - $time < $Config->core['session_expire'] * $Config->core['update_ratio'] / 100) {
405
			$session_data['expire'] = $time + $Config->core['session_expire'];
406
			$this->update($session_data);
407
			$this->cache->set($session_data['id'], $session_data);
408
		}
409
		unset($session_data['data']);
410
		Event::instance()->fire(
411
			'System/Session/load',
412
			[
413
				'session_data' => $session_data
414
			]
415
		);
416
		return $this->load_initialization($session_data['id'], $session_data['user']);
417
	}
418
	/**
419
	 * Initialize session (set user id, session id and update who user is)
420
	 *
421
	 * @param string $session_id
422
	 * @param int    $user_id
423
	 *
424
	 * @return int User id
425
	 */
426
	protected function load_initialization ($session_id, $user_id) {
427
		$this->session_id = $session_id;
428
		$this->user_id    = $user_id;
429
		$this->update_user_is();
430
		return $user_id;
431
	}
432
	/**
433
	 * Whether profile is activated, not disabled and not blocked
434
	 *
435
	 * @param int $user
436
	 *
437
	 * @return bool
438
	 */
439
	protected function is_user_active ($user) {
440
		/**
441
		 * Optimization, more data requested than actually used here, because data will be requested later, and it would be nice to have that data cached
442
		 */
443
		$data = User::instance()->get(
444
			[
445
				'login',
446
				'username',
447
				'language',
448
				'timezone',
449
				'status',
450
				'block_until',
451
				'avatar'
452
			],
453
			$user
454
		);
455
		if (!$data) {
456
			return false;
457
		}
458
		$L    = Language::instance();
459
		$Page = Page::instance();
460
		switch ($data['status']) {
461
			case User::STATUS_INACTIVE:
462
				/**
463
				 * If user is disabled
464
				 */
465
				$Page->warning($L->your_account_disabled);
466
				return false;
467
			case User::STATUS_NOT_ACTIVATED:
468
				/**
469
				 * If user is not active
470
				 */
471
				$Page->warning($L->your_account_is_not_active);
472
				return false;
473
		}
474
		if ($data['block_until'] > time()) {
475
			/**
476
			 * If user if blocked
477
			 */
478
			$Page->warning($L->your_account_blocked_until.' '.date($L->_datetime, $data['block_until']));
479
			return false;
480
		}
481
		return true;
482
	}
483
	/**
484
	 * Create the session for the user with specified id
485
	 *
486
	 * @param int  $user
487
	 * @param bool $delete_current_session
488
	 *
489
	 * @return false|string Session id on success, `false` otherwise
490
	 */
491
	function add ($user, $delete_current_session = true) {
492
		$user = (int)$user;
493
		if (!$user) {
494
			return false;
495
		}
496
		if ($delete_current_session && is_md5($this->session_id)) {
497
			$this->del_internal($this->session_id, false);
498
		}
499
		if (!$this->is_user_active($user)) {
500
			/**
501
			 * If data was not loaded or account is not active - create guest session
502
			 */
503
			return $this->add(User::GUEST_ID);
504
		}
505
		$session_data = $this->create_unique_session($user);
506
		_setcookie('session', $session_data['id'], $session_data['expire'], true);
507
		$this->load_initialization($session_data['id'], $session_data['user']);
508
		/**
509
		 * Delete old sessions using probability and system configuration of inserts limits and update ratio
510
		 */
511
		$Config = Config::instance();
512
		if (mt_rand(0, $Config->core['inserts_limit']) < $Config->core['inserts_limit'] / 100 * (100 - $Config->core['update_ratio']) / 5) {
513
			$this->delete_old_sessions();
514
		}
515
		Event::instance()->fire(
516
			'System/Session/add',
517
			[
518
				'session_data' => $session_data
519
			]
520
		);
521
		return $session_data['id'];
522
	}
523
	/**
524
	 * @param int $user
525
	 *
526
	 * @return array Session data
527
	 */
528
	protected function create_unique_session ($user) {
529
		$Config = Config::instance();
530
		/**
531
		 * @var \cs\_SERVER $_SERVER
532
		 */
533
		$remote_addr = ip2hex($_SERVER->remote_addr);
534
		$ip          = ip2hex($_SERVER->ip);
535
		/**
536
		 * Many guests open only one page (or do not store any cookies), so create guest session only for 5 minutes max initially
537
		 */
538
		$expire_in = $user == User::GUEST_ID ? min($Config->core['session_expire'], self::INITIAL_SESSION_EXPIRATION) : $Config->core['session_expire'];
539
		$expire    = time() + $expire_in;
540
		/**
541
		 * Create unique session
542
		 */
543
		$session_data = [
544
			'id'          => null,
545
			'user'        => $user,
546
			'created'     => time(),
547
			'expire'      => $expire,
548
			'user_agent'  => $_SERVER->user_agent,
549
			'remote_addr' => $remote_addr,
550
			'ip'          => $ip,
551
			'data'        => []
552
		];
553
		do {
554
			$session_data['id'] = md5(random_bytes(1000));
555
		} while (!$this->create($session_data));
556
		return $session_data;
557
	}
558
	/**
559
	 * Destroying of the session
560
	 *
561
	 * @param null|string $session_id
562
	 *
563
	 * @return bool
564
	 */
565
	function del ($session_id = null) {
566
		return (bool)$this->del_internal($session_id);
567
	}
568
	/**
569
	 * Deletion of the session
570
	 *
571
	 * @param string|null $session_id
572
	 * @param bool        $create_guest_session
573
	 *
574
	 * @return bool
575
	 */
576
	protected function del_internal ($session_id = null, $create_guest_session = true) {
577
		$session_id = $session_id ?: $this->session_id;
578
		if (!is_md5($session_id)) {
579
			return false;
580
		}
581
		Event::instance()->fire(
582
			'System/Session/del/before',
583
			[
584
				'id' => $session_id
585
			]
586
		);
587
		unset($this->cache->$session_id);
588
		$this->session_id = false;
589
		_setcookie('session', '');
590
		$result = $this->delete($session_id);
1 ignored issue
show
Security Bug introduced by
It seems like $session_id defined by $session_id ?: $this->session_id on line 577 can also be of type false; however, cs\CRUD::delete() does only seem to accept integer|array<integer,in...g|array<integer,string>, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
591
		if ($result) {
592
			if ($create_guest_session) {
593
				return (bool)$this->add(User::GUEST_ID);
594
			}
595
			Event::instance()->fire(
596
				'System/Session/del/after',
597
				[
598
					'id' => $session_id
599
				]
600
			);
601
		}
602
		return (bool)$result;
603
	}
604
	/**
605
	 * Delete all old sessions from DB
606
	 */
607
	protected function delete_old_sessions () {
608
		$this->db_prime()->aq(
609
			"DELETE FROM `[prefix]sessions`
610
			WHERE `expire` < ".time()
611
		);
612
	}
613
	/**
614
	 * Deletion of all user sessions
615
	 *
616
	 * @param false|int $user If not specified - current user assumed
617
	 *
618
	 * @return bool
619
	 */
620
	function del_all ($user = false) {
621
		$user = $user ?: $this->user_id;
622
		Event::instance()->fire(
623
			'System/Session/del_all',
624
			[
625
				'id' => $user
626
			]
627
		);
628
		$sessions = $this->db_prime()->qfas(
629
			"SELECT `id`
630
			FROM `[prefix]sessions`
631
			WHERE `user` = '$user'"
632
		);
633
		if (is_array($sessions)) {
634
			if (!$this->delete($sessions)) {
635
				return false;
636
			}
637
			foreach ($sessions as $session) {
638
				unset($this->cache->$session);
639
			}
640
		}
641
		return true;
642
	}
643
	/**
644
	 * Get data, stored with session
645
	 *
646
	 * @param string      $item
647
	 * @param null|string $session_id
648
	 *
649
	 * @return false|mixed
650
	 *
651
	 */
652
	function get_data ($item, $session_id = null) {
653
		$session_data = $this->get_data_internal($session_id);
654
		return isset($session_data['data'][$item]) ? $session_data['data'][$item] : false;
655
	}
656
	/*
657
	 * @param null|string $session_id
658
	 *
659
	 * @return array|false
660
	 */
661
	protected function get_data_internal ($session_id) {
662
		$session_id = $session_id ?: $this->session_id;
663
		return is_md5($session_id) ? $this->get_internal($session_id) : false;
664
	}
665
	/**
666
	 * Store data with session
667
	 *
668
	 * @param string      $item
669
	 * @param mixed       $value
670
	 * @param null|string $session_id
671
	 *
672
	 * @return bool
673
	 *
674
	 */
675
	function set_data ($item, $value, $session_id = null) {
676
		$session_data = $this->get_data_internal($session_id);
677
		/**
678
		 * If there is no session yet - let's create one
679
		 */
680
		if (!$session_data) {
681
			$session_id   = $this->add(User::GUEST_ID);
682
			$session_data = $this->get_data_internal($session_id);
1 ignored issue
show
Security Bug introduced by
It seems like $session_id defined by $this->add(\cs\User::GUEST_ID) on line 681 can also be of type false; however, cs\Session::get_data_internal() does only seem to accept null|string, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
683
		}
684
		if (!isset($session_data['data'])) {
685
			return false;
686
		}
687
		$session_data['data'][$item] = $value;
688
		return $this->update($session_data) && $this->cache->del($session_id);
2 ignored issues
show
Security Bug introduced by
It seems like $session_data can also be of type false; however, cs\CRUD::update() does only seem to accept array, did you maybe forget to handle an error condition?
Loading history...
Bug introduced by
It seems like $session_id can also be of type false or null; however, cs\Cache\Prefix::del() does only seem to accept string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
689
	}
690
	/**
691
	 * Delete data, stored with session
692
	 *
693
	 * @param string      $item
694
	 * @param null|string $session_id
695
	 *
696
	 * @return bool
697
	 *
698
	 */
699
	function del_data ($item, $session_id = null) {
700
		$session_data = $this->get_data_internal($session_id);
701
		if (!isset($session_data['data'])) {
702
			return false;
703
		}
704
		if (!isset($session_data['data'][$item])) {
705
			return true;
706
		}
707
		return $this->update($session_data) && $this->cache->del($session_id);
1 ignored issue
show
Security Bug introduced by
It seems like $session_data defined by $this->get_data_internal($session_id) on line 700 can also be of type false; however, cs\CRUD::update() does only seem to accept array, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
708
	}
709
}
710