Completed
Push — master ( c3ae58...fbef1b )
by Nazar
04:32
created

Session::all_bots()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 23
Code Lines 8

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 23
rs 9.0856
cc 3
eloc 8
nc 2
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 false|int
54
	 */
55
	protected $user_id  = false;
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
		/**
111
		 * If session not found and visitor is not bot - create new session
112
		 */
113
		if (!$this->user_id) {
114
			$this->user_id = User::GUEST_ID;
115
			/**
116
			 * Do not create session for API requests
117
			 */
118
			if (!api_path()) {
119
				$this->add();
120
			}
121
		}
122
		$this->update_user_is();
123
		Event::instance()->fire('System/Session/init/after');
124
	}
125
	/**
126
	 * Try to determine whether visitor is a known bot, bots have no sessions
127
	 */
128
	protected function bots_detection () {
129
		$Cache = $this->users_cache;
130
		/**
131
		 * @var \cs\_SERVER $_SERVER
132
		 */
133
		/**
134
		 * For bots: login is user agent, email is IP
135
		 */
136
		$login    = $_SERVER->user_agent;
137
		$email    = $_SERVER->ip;
138
		$bot_hash = hash('sha224', $login.$email);
139
		/**
140
		 * If bot is cached
141
		 */
142
		$this->user_id = $Cache->$bot_hash;
143
		/**
144
		 * If bot found in cache - exit from here
145
		 */
146
		if ($this->user_id) {
147
			return;
148
		}
149
		/**
150
		 * Try to find bot among known bots
151
		 */
152
		foreach ($this->all_bots() as $bot) {
153
			if ($this->is_this_bot($bot, $login, $email)) {
154
				/**
155
				 * If bot found - save it in cache
156
				 */
157
				$this->user_id    = $bot['id'];
158
				$Cache->$bot_hash = $bot['id'];
159
				return;
160
			}
161
		}
162
	}
163
	/**
164
	 * Get list of all bots
165
	 *
166
	 * @return array
167
	 */
168
	protected function all_bots () {
169
		return $this->users_cache->get(
170
			'bots',
171
			function () {
172
				return $this->db()->qfa(
173
					[
174
						"SELECT
175
							`u`.`id`,
176
							`u`.`login`,
177
							`u`.`email`
178
						FROM `[prefix]users` AS `u`
179
							INNER JOIN `[prefix]users_groups` AS `g`
180
						ON `u`.`id` = `g`.`id`
181
						WHERE
182
							`g`.`group`		= '%s' AND
183
							`u`.`status`	= '%s'",
184
						User::BOT_GROUP_ID,
185
						User::STATUS_ACTIVE
186
					]
187
				) ?: [];
188
			}
189
		) ?: [];
190
	}
191
	/**
192
	 * Check whether user agent and IP (login and email for bots) corresponds to passed bot data
193
	 *
194
	 * @param array  $bot
195
	 * @param string $login
196
	 * @param string $email
197
	 *
198
	 * @return bool
199
	 */
200
	protected function is_this_bot ($bot, $login, $email) {
201
		return
202
			(
203
				$bot['login'] &&
204
				(
205
					strpos($login, $bot['login']) !== false ||
206
					_preg_match($bot['login'], $login)
207
				)
208
			) ||
209
			(
210
				$bot['email'] &&
211
				(
212
					$email === $bot['email'] ||
213
					_preg_match($bot['email'], $email)
214
				)
215
			);
216
	}
217
	/**
218
	 * Updates information about who is user accessed by methods ::guest() ::bot() ::user() admin()
219
	 */
220
	protected function update_user_is () {
221
		$this->is_guest = false;
222
		$this->is_bot   = false;
223
		$this->is_user  = false;
224
		$this->is_admin = false;
225
		if ($this->user_id == User::GUEST_ID) {
226
			$this->is_guest = true;
227
			return;
228
		}
229
		/**
230
		 * Checking of user type
231
		 */
232
		$groups = User::instance()->get_groups($this->user_id) ?: [];
233
		if (in_array(User::ADMIN_GROUP_ID, $groups)) {
234
			$this->is_admin = true;
235
			$this->is_user  = true;
236
		} elseif (in_array(User::USER_GROUP_ID, $groups)) {
237
			$this->is_user = true;
238
		} elseif (in_array(User::BOT_GROUP_ID, $groups)) {
239
			$this->is_guest = true;
240
			$this->is_bot   = true;
241
		}
242
	}
243
	/**
244
	 * Is admin
245
	 *
246
	 * @return bool
247
	 */
248
	function admin () {
249
		return $this->is_admin;
250
	}
251
	/**
252
	 * Is user
253
	 *
254
	 * @return bool
255
	 */
256
	function user () {
257
		return $this->is_user;
258
	}
259
	/**
260
	 * Is guest
261
	 *
262
	 * @return bool
263
	 */
264
	function guest () {
265
		return $this->is_guest;
266
	}
267
	/**
268
	 * Is bot
269
	 *
270
	 * @return bool
271
	 */
272
	function bot () {
273
		return $this->is_bot;
274
	}
275
	/**
276
	 * Returns id of current session
277
	 *
278
	 * @return false|string
279
	 */
280
	function get_id () {
281
		if ($this->user_id == User::GUEST_ID && $this->bot()) {
282
			return false;
283
		}
284
		return $this->session_id ?: false;
285
	}
286
	/**
287
	 * Returns user id of current session
288
	 *
289
	 * @return false|int
290
	 */
291
	function get_user () {
292
		return $this->user_id;
293
	}
294
	/**
295
	 * Returns session details by session id
296
	 *
297
	 * @param false|null|string $session_id If `null` - loaded from `$this->session_id`, and if that also empty - from cookies
298
	 *
299
	 * @return false|array
300
	 */
301
	function get ($session_id) {
302
		$session_data = $this->get_internal($session_id);
303
		if ($session_data) {
304
			unset($session_data['data']);
305
		}
306
		return $session_data;
307
	}
308
	/**
309
	 * @param false|null|string $session_id
310
	 *
311
	 * @return false|array
312
	 */
313
	protected function get_internal ($session_id) {
314
		if (!$session_id) {
315
			if (!$this->session_id) {
316
				$this->session_id = _getcookie('session');
317
			}
318
			$session_id = $this->session_id;
319
		}
320
		if (!is_md5($session_id)) {
321
			return false;
322
		}
323
		$data = $this->cache->get(
324
			$session_id,
325
			function () use ($session_id) {
326
				$data = $this->read($session_id);
327
				if (!$data || $data['expire'] <= time()) {
328
					return false;
329
				}
330
				$data['data'] = $data['data'] ?: [];
331
				return $data;
332
			}
333
		);
334
		return $this->is_good_session($data) ? $data : false;
335
	}
336
	/**
337
	 * Check whether session was not expired, user agent and IP corresponds to what is expected and user is actually active
338
	 *
339
	 * @param mixed $session_data
340
	 *
341
	 * @return bool
342
	 */
343
	protected function is_good_session ($session_data) {
344
		return
345
			isset($session_data['expire'], $session_data['user']) &&
346
			$session_data['expire'] > time() &&
347
			$this->is_user_active($session_data['user']);
348
	}
349
	/**
350
	 * Whether session data belongs to current visitor (user agent, remote addr and ip check)
351
	 *
352
	 * @param string $session_id
353
	 * @param string $user_agent
354
	 * @param string $remote_addr
355
	 * @param string $ip
356
	 *
357
	 * @return bool
358
	 */
359
	function is_session_owner ($session_id, $user_agent, $remote_addr, $ip) {
360
		$session_data = $this->get($session_id);
361
		return $session_data ? $this->is_session_owner_internal($session_data, $user_agent, $remote_addr, $ip) : false;
362
	}
363
	/**
364
	 * Whether session data belongs to current visitor (user agent, remote addr and ip check)
365
	 *
366
	 * @param array       $session_data
367
	 * @param string|null $user_agent
368
	 * @param string|null $remote_addr
369
	 * @param string|null $ip
370
	 *
371
	 * @return bool
372
	 */
373
	protected function is_session_owner_internal ($session_data, $user_agent = null, $remote_addr = null, $ip = null) {
374
		/**
375
		 * md5() as protection against timing attacks
376
		 *
377
		 * @var \cs\_SERVER $_SERVER
378
		 */
379
		if ($user_agent === null && $remote_addr === null && $ip === null) {
380
			$user_agent  = $_SERVER->user_agent;
381
			$remote_addr = $_SERVER->remote_addr;
382
			$ip          = $_SERVER->ip;
383
		}
384
		return
385
			md5($session_data['user_agent']) == md5($user_agent) &&
386
			(
387
				!Config::instance()->core['remember_user_ip'] ||
388
				(
389
					md5($session_data['remote_addr']) == md5(ip2hex($remote_addr)) &&
390
					md5($session_data['ip']) == md5(ip2hex($ip))
391
				)
392
			);
393
	}
394
	/**
395
	 * Load session by id and return id of session owner (user), update session expiration
396
	 *
397
	 * @param false|null|string $session_id If not specified - loaded from `$this->session_id`, and if that also empty - from cookies
398
	 *
399
	 * @return int User id
400
	 */
401
	function load ($session_id = null) {
402
		if ($this->user_id == User::GUEST_ID && $this->bot()) {
403
			return User::GUEST_ID;
404
		}
405
		$session_data = $this->get_internal($session_id);
406
		if (!$session_data || !$this->is_session_owner_internal($session_data)) {
407
			$this->add(User::GUEST_ID);
408
			return User::GUEST_ID;
409
		}
410
		/**
411
		 * Updating last online time and ip
412
		 */
413
		$Config = Config::instance();
414
		$time   = time();
415
		if ($session_data['expire'] - $time < $Config->core['session_expire'] * $Config->core['update_ratio'] / 100) {
416
			$session_data['expire'] = $time + $Config->core['session_expire'];
417
			$this->update($session_data);
418
			$this->cache->set($session_data['id'], $session_data);
419
		}
420
		unset($session_data['data']);
421
		Event::instance()->fire(
422
			'System/Session/load',
423
			[
424
				'session_data' => $session_data
425
			]
426
		);
427
		return $this->load_initialization($session_data['id'], $session_data['user']);
428
	}
429
	/**
430
	 * Initialize session (set user id, session id and update who user is)
431
	 *
432
	 * @param string $session_id
433
	 * @param int    $user_id
434
	 *
435
	 * @return int User id
436
	 */
437
	protected function load_initialization ($session_id, $user_id) {
438
		$this->session_id = $session_id;
439
		$this->user_id    = $user_id;
440
		$this->update_user_is();
441
		return $user_id;
442
	}
443
	/**
444
	 * Whether profile is activated, not disabled and not blocked
445
	 *
446
	 * @param int $user
447
	 *
448
	 * @return bool
449
	 */
450
	protected function is_user_active ($user) {
451
		/**
452
		 * Optimization, more data requested than actually used here, because data will be requested later, and it would be nice to have that data cached
453
		 */
454
		$data = User::instance()->get(
455
			[
456
				'login',
457
				'username',
458
				'language',
459
				'timezone',
460
				'status',
461
				'block_until',
462
				'avatar'
463
			],
464
			$user
465
		);
466
		if (!$data) {
467
			return false;
468
		}
469
		$L    = Language::instance();
470
		$Page = Page::instance();
471
		switch ($data['status']) {
472
			case User::STATUS_INACTIVE:
473
				/**
474
				 * If user is disabled
475
				 */
476
				$Page->warning($L->your_account_disabled);
477
				return false;
478
			case User::STATUS_NOT_ACTIVATED:
479
				/**
480
				 * If user is not active
481
				 */
482
				$Page->warning($L->your_account_is_not_active);
483
				return false;
484
		}
485
		if ($data['block_until'] > time()) {
486
			/**
487
			 * If user if blocked
488
			 */
489
			$Page->warning($L->your_account_blocked_until.' '.date($L->_datetime, $data['block_until']));
490
			return false;
491
		}
492
		return true;
493
	}
494
	/**
495
	 * Create the session for the user with specified id
496
	 *
497
	 * @param false|int $user
498
	 * @param bool      $delete_current_session
499
	 *
500
	 * @return false|string Session id on success, `false` otherwise
501
	 */
502
	function add ($user = false, $delete_current_session = true) {
503
		$user = (int)$user ?: User::GUEST_ID;
504
		if ($delete_current_session && is_md5($this->session_id)) {
505
			$this->del_internal($this->session_id, false);
1 ignored issue
show
Security Bug introduced by
It seems like $this->session_id can also be of type false; however, cs\Session::del_internal() does only seem to accept string|null, did you maybe forget to handle an error condition?
Loading history...
506
		}
507
		if (!$this->is_user_active($user)) {
508
			/**
509
			 * If data was not loaded or account is not active - create guest session
510
			 */
511
			return $this->add(User::GUEST_ID);
512
		}
513
		$session_data = $this->create_unique_session($user);
514
		_setcookie('session', $session_data['id'], $session_data['expire'], true);
515
		$this->load_initialization($session_data['id'], $session_data['user']);
516
		/**
517
		 * Delete old sessions using probability and system configuration of inserts limits and update ratio
518
		 */
519
		$Config = Config::instance();
520
		if (mt_rand(0, $Config->core['inserts_limit']) < $Config->core['inserts_limit'] / 100 * (100 - $Config->core['update_ratio']) / 5) {
521
			$this->delete_old_sessions();
522
		}
523
		Event::instance()->fire(
524
			'System/Session/add',
525
			[
526
				'session_data' => $session_data
527
			]
528
		);
529
		return $session_data['id'];
530
	}
531
	/**
532
	 * @param int $user
533
	 *
534
	 * @return array Session data
535
	 */
536
	protected function create_unique_session ($user) {
537
		$Config = Config::instance();
538
		/**
539
		 * @var \cs\_SERVER $_SERVER
540
		 */
541
		$remote_addr = ip2hex($_SERVER->remote_addr);
542
		$ip          = ip2hex($_SERVER->ip);
543
		/**
544
		 * Many guests open only one page (or do not store any cookies), so create guest session only for 5 minutes max initially
545
		 */
546
		$expire_in = $user == User::GUEST_ID ? min($Config->core['session_expire'], self::INITIAL_SESSION_EXPIRATION) : $Config->core['session_expire'];
547
		$expire    = time() + $expire_in;
548
		/**
549
		 * Create unique session
550
		 */
551
		$session_data = [
552
			'id'          => null,
553
			'user'        => $user,
554
			'created'     => time(),
555
			'expire'      => $expire,
556
			'user_agent'  => $_SERVER->user_agent,
557
			'remote_addr' => $remote_addr,
558
			'ip'          => $ip,
559
			'data'        => []
560
		];
561
		do {
562
			$session_data['id'] = md5(random_bytes(1000));
563
		} while (!$this->create($session_data));
564
		return $session_data;
565
	}
566
	/**
567
	 * Destroying of the session
568
	 *
569
	 * @param null|string $session_id
570
	 *
571
	 * @return bool
572
	 */
573
	function del ($session_id = null) {
574
		return (bool)$this->del_internal($session_id);
575
	}
576
	/**
577
	 * Deletion of the session
578
	 *
579
	 * @param string|null $session_id
580
	 * @param bool        $create_guest_session
581
	 *
582
	 * @return bool
583
	 */
584
	protected function del_internal ($session_id = null, $create_guest_session = true) {
585
		$session_id = $session_id ?: $this->session_id;
586
		if (!is_md5($session_id)) {
587
			return false;
588
		}
589
		Event::instance()->fire(
590
			'System/Session/del/before',
591
			[
592
				'id' => $session_id
593
			]
594
		);
595
		unset($this->cache->$session_id);
596
		$this->session_id = false;
597
		_setcookie('session', '');
598
		$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 585 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...
599
		if ($result) {
600
			if ($create_guest_session) {
601
				return (bool)$this->add(User::GUEST_ID);
602
			}
603
			Event::instance()->fire(
604
				'System/Session/del/after',
605
				[
606
					'id' => $session_id
607
				]
608
			);
609
		}
610
		return (bool)$result;
611
	}
612
	/**
613
	 * Delete all old sessions from DB
614
	 */
615
	protected function delete_old_sessions () {
616
		$this->db_prime()->aq(
617
			"DELETE FROM `[prefix]sessions`
618
			WHERE `expire` < ".time()
619
		);
620
	}
621
	/**
622
	 * Deletion of all user sessions
623
	 *
624
	 * @param false|int $user If not specified - current user assumed
625
	 *
626
	 * @return bool
627
	 */
628
	function del_all ($user = false) {
629
		$user = $user ?: $this->user_id;
630
		Event::instance()->fire(
631
			'System/Session/del_all',
632
			[
633
				'id' => $user
634
			]
635
		);
636
		$sessions = $this->db_prime()->qfas(
637
			"SELECT `id`
638
			FROM `[prefix]sessions`
639
			WHERE `user` = '$user'"
640
		);
641
		if (is_array($sessions)) {
642
			if (!$this->delete($sessions)) {
643
				return false;
644
			}
645
			foreach ($sessions as $session) {
646
				unset($this->cache->$session);
647
			}
648
		}
649
		return true;
650
	}
651
	/**
652
	 * Get data, stored with session
653
	 *
654
	 * @param string      $item
655
	 * @param null|string $session_id
656
	 *
657
	 * @return false|mixed
658
	 *
659
	 */
660
	function get_data ($item, $session_id = null) {
661
		$session_data = $this->get_data_internal($session_id);
662
		return isset($session_data['data'][$item]) ? $session_data['data'][$item] : false;
663
	}
664
	/*
1 ignored issue
show
Unused Code Comprehensibility introduced by
42% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
665
	 * @param null|string $session_id
666
	 *
667
	 * @return array|false
668
	 */
669
	protected function get_data_internal ($session_id) {
670
		$session_id = $session_id ?: $this->session_id;
671
		return is_md5($session_id) ? $this->get_internal($session_id) : false;
672
	}
673
	/**
674
	 * Store data with session
675
	 *
676
	 * @param string      $item
677
	 * @param mixed       $value
678
	 * @param null|string $session_id
679
	 *
680
	 * @return bool
681
	 *
682
	 */
683
	function set_data ($item, $value, $session_id = null) {
684
		$session_data = $this->get_data_internal($session_id);
685
		if (!isset($session_data['data'])) {
686
			return false;
687
		}
688
		$session_data['data'][$item] = $value;
689
		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 684 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...
690
	}
691
	/**
692
	 * Delete data, stored with session
693
	 *
694
	 * @param string      $item
695
	 * @param null|string $session_id
696
	 *
697
	 * @return bool
698
	 *
699
	 */
700
	function del_data ($item, $session_id = null) {
701
		$session_data = $this->get_data_internal($session_id);
702
		if (!isset($session_data['data'])) {
703
			return false;
704
		}
705
		if (!isset($session_data['data'][$item])) {
706
			return true;
707
		}
708
		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 701 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...
709
	}
710
}
711