Aleksander Machniak
2014-09-12 34a0902089a410d1f7dda78d1f8b0771333c09df
commit | author | age
929a50 1 <?php
A 2
3 /*
4  +-----------------------------------------------------------------------+
e019f2 5  | This file is part of the Roundcube Webmail client                     |
14291c 6  | Copyright (C) 2005-2014, The Roundcube Dev Team                       |
1a716d 7  | Copyright (C) 2011, Kolab Systems AG                                  |
7fe381 8  |                                                                       |
T 9  | Licensed under the GNU General Public License version 3 or            |
10  | any later version with exceptions for skins & plugins.                |
11  | See the README file for a full license statement.                     |
929a50 12  |                                                                       |
A 13  | PURPOSE:                                                              |
14  |   Provide database supported session management                       |
15  +-----------------------------------------------------------------------+
16  | Author: Thomas Bruederli <roundcube@gmail.com>                        |
17  | Author: Aleksander Machniak <alec@alec.pl>                            |
18  +-----------------------------------------------------------------------+
19 */
20
d062db 21 /**
T 22  * Class to provide database supported session storage
23  *
9ab346 24  * @package    Framework
AM 25  * @subpackage Core
d062db 26  * @author     Thomas Bruederli <roundcube@gmail.com>
T 27  * @author     Aleksander Machniak <alec@alec.pl>
28  */
929a50 29 class rcube_session
A 30 {
0d2144 31     private $db;
AM 32     private $ip;
33     private $start;
34     private $changed;
3dbe4f 35     private $time_diff = 0;
f0a715 36     private $reloaded = false;
1e7d12 37     private $appends = array();
0d2144 38     private $unsets = array();
AM 39     private $gc_handlers = array();
40     private $cookiename = 'roundcube_sessauth';
41     private $vars;
42     private $key;
43     private $now;
44     private $secret = '';
45     private $ip_check = false;
46     private $logging = false;
42de33 47     private $storage;
0d2144 48     private $memcache;
17acd5 49
TB 50     /**
51      * Blocks session data from being written to database.
52      * Can be used if write-race conditions are to be expected
53      * @var boolean
54      */
55     public $nowrite = false;
929a50 56
A 57
0d2144 58     /**
AM 59      * Default constructor
60      */
61     public function __construct($db, $config)
62     {
63         $this->db      = $db;
64         $this->start   = microtime(true);
4d480b 65         $this->ip      = rcube_utils::remote_addr();
0d2144 66         $this->logging = $config->get('log_session', false);
929a50 67
0d2144 68         $lifetime = $config->get('session_lifetime', 1) * 60;
AM 69         $this->set_lifetime($lifetime);
929a50 70
0d2144 71         // use memcache backend
42de33 72         $this->storage = $config->get('session_storage', 'db');
TB 73         if ($this->storage == 'memcache') {
0d2144 74             $this->memcache = rcube::get_instance()->get_memcache();
929a50 75
0d2144 76             // set custom functions for PHP session management if memcache is available
AM 77             if ($this->memcache) {
42de33 78                 ini_set('session.serialize_handler', 'php');
TB 79
0d2144 80                 session_set_save_handler(
AM 81                     array($this, 'open'),
82                     array($this, 'close'),
83                     array($this, 'mc_read'),
84                     array($this, 'mc_write'),
85                     array($this, 'mc_destroy'),
86                     array($this, 'gc'));
87             }
88             else {
89                 rcube::raise_error(array('code' => 604, 'type' => 'db',
90                     'line' => __LINE__, 'file' => __FILE__,
91                     'message' => "Failed to connect to memcached. Please check configuration"),
92                 true, true);
93             }
94         }
42de33 95         else if ($this->storage != 'php') {
TB 96             ini_set('session.serialize_handler', 'php');
97
0d2144 98             // set custom functions for PHP session management
AM 99             session_set_save_handler(
100                 array($this, 'open'),
101                 array($this, 'close'),
102                 array($this, 'db_read'),
103                 array($this, 'db_write'),
104                 array($this, 'db_destroy'),
3dbe4f 105                 array($this, 'gc'));
34a090 106
AM 107             $this->table_name = $this->db->table_name('session', true);
42de33 108         }
TB 109     }
110
111
112     /**
113      * Wrapper for session_start()
114      */
115     public function start()
116     {
117         session_start();
118
119         // copy some session properties to object vars
120         if ($this->storage == 'php') {
121             $this->key     = session_id();
122             $this->ip      = $_SESSION['__IP'];
123             $this->changed = $_SESSION['__MTIME'];
0d2144 124         }
929a50 125     }
A 126
ca1f56 127
0d2144 128     public function open($save_path, $session_name)
AM 129     {
130         return true;
929a50 131     }
ca1f56 132
68f39e 133
0d2144 134     public function close()
AM 135     {
136         return true;
137     }
138
139
140     /**
141      * Delete session data for the given key
142      *
143      * @param string Session ID
144      */
145     public function destroy($key)
146     {
147         return $this->memcache ? $this->mc_destroy($key) : $this->db_destroy($key);
148     }
149
150
151     /**
42de33 152      * Wrapper for session_write_close()
TB 153      */
154     public function write_close()
155     {
156         if ($this->storage == 'php') {
157             $_SESSION['__IP'] = $this->ip;
158             $_SESSION['__MTIME'] = time();
159         }
160
161         session_write_close();
3dbe4f 162
AM 163         // write_close() is called on script shutdown, see rcube::shutdown()
164         // execute cleanup functionality if enabled by session gc handler
165         // we do this after closing the session for better performance
166         $this->gc_shutdown();
42de33 167     }
TB 168
169
170     /**
0d2144 171      * Read session data from database
AM 172      *
173      * @param string Session ID
174      *
175      * @return string Session vars
176      */
177     public function db_read($key)
178     {
179         $sql_result = $this->db->query(
34a090 180             "SELECT `vars`, `ip`, `changed`, " . $this->db->now() . " AS ts"
AM 181             . " FROM {$this->table_name} WHERE `sess_id` = ?", $key);
0d2144 182
AM 183         if ($sql_result && ($sql_arr = $this->db->fetch_assoc($sql_result))) {
3dbe4f 184             $this->time_diff = time() - strtotime($sql_arr['ts']);
AM 185             $this->changed   = strtotime($sql_arr['changed']);
186             $this->ip        = $sql_arr['ip'];
187             $this->vars      = base64_decode($sql_arr['vars']);
188             $this->key       = $key;
0d2144 189
AM 190             return !empty($this->vars) ? (string) $this->vars : '';
191         }
192
193         return null;
194     }
195
196
197     /**
198      * Save session data.
199      * handler for session_read()
200      *
201      * @param string Session ID
202      * @param string Serialized session vars
203      *
204      * @return boolean True on success
205      */
206     public function db_write($key, $vars)
207     {
34a090 208         $now = $this->db->now();
AM 209         $ts  = microtime(true);
14291c 210
TB 211         if ($this->nowrite)
212             return true;
0d2144 213
AM 214         // no session row in DB (db_read() returns false)
215         if (!$this->key) {
216             $oldvars = null;
217         }
218         // use internal data from read() for fast requests (up to 0.5 sec.)
219         else if ($key == $this->key && (!$this->vars || $ts - $this->start < 0.5)) {
220             $oldvars = $this->vars;
221         }
222         else { // else read data again from DB
223             $oldvars = $this->db_read($key);
224         }
225
226         if ($oldvars !== null) {
227             $newvars = $this->_fixvars($vars, $oldvars);
228
229             if ($newvars !== $oldvars) {
34a090 230                 $this->db->query("UPDATE {$this->table_name} "
AM 231                     . "SET `changed` = $now, `vars` = ? WHERE `sess_id` = ?",
3dbe4f 232                     base64_encode($newvars), $key);
0d2144 233             }
3dbe4f 234             else if ($ts - $this->changed + $this->time_diff > $this->lifetime / 2) {
34a090 235                 $this->db->query("UPDATE {$this->table_name} SET `changed` = $now"
AM 236                     . " WHERE `sess_id` = ?", $key);
0d2144 237             }
AM 238         }
239         else {
34a090 240             $this->db->query("INSERT INTO {$this->table_name}"
AM 241                 . " (`sess_id`, `vars`, `ip`, `created`, `changed`)"
3dbe4f 242                 . " VALUES (?, ?, ?, $now, $now)",
AM 243                 $key, base64_encode($vars), (string)$this->ip);
0d2144 244         }
AM 245
246         return true;
247     }
248
249
250     /**
251      * Merge vars with old vars and apply unsets
252      */
253     private function _fixvars($vars, $oldvars)
254     {
255         if ($oldvars !== null) {
256             $a_oldvars = $this->unserialize($oldvars);
257             if (is_array($a_oldvars)) {
f0a715 258                 // remove unset keys on oldvars
TB 259                 foreach ((array)$this->unsets as $var) {
390626 260                     if (isset($a_oldvars[$var])) {
VB 261                         unset($a_oldvars[$var]);
4034a7 262                     }
TB 263                     else {
264                         $path = explode('.', $var);
265                         $k = array_pop($path);
266                         $node = &$this->get_node($path, $a_oldvars);
267                         unset($node[$k]);
268                     }
f0a715 269                 }
0d2144 270
AM 271                 $newvars = $this->serialize(array_merge(
272                     (array)$a_oldvars, (array)$this->unserialize($vars)));
273             }
274             else {
275                 $newvars = $vars;
276             }
277         }
278
279         $this->unsets = array();
280         return $newvars;
281     }
282
283
284     /**
285      * Handler for session_destroy()
286      *
287      * @param string Session ID
288      *
289      * @return boolean True on success
290      */
291     public function db_destroy($key)
292     {
293         if ($key) {
34a090 294             $this->db->query("DELETE FROM {$this->table_name} WHERE `sess_id` = ?", $key);
0d2144 295         }
AM 296
297         return true;
929a50 298     }
A 299
68f39e 300
0d2144 301     /**
AM 302      * Read session data from memcache
303      *
304      * @param string Session ID
305      * @return string Session vars
306      */
307     public function mc_read($key)
308     {
309         if ($value = $this->memcache->get($key)) {
310             $arr = unserialize($value);
311             $this->changed = $arr['changed'];
312             $this->ip      = $arr['ip'];
313             $this->vars    = $arr['vars'];
314             $this->key     = $key;
68f39e 315
0d2144 316             return !empty($this->vars) ? (string) $this->vars : '';
929a50 317         }
0d2144 318
AM 319         return null;
929a50 320     }
A 321
322
0d2144 323     /**
AM 324      * Save session data.
325      * handler for session_read()
326      *
327      * @param string Session ID
328      * @param string Serialized session vars
329      *
330      * @return boolean True on success
331      */
332     public function mc_write($key, $vars)
333     {
334         $ts = microtime(true);
88ca38 335
0d2144 336         // no session data in cache (mc_read() returns false)
AM 337         if (!$this->key)
338             $oldvars = null;
339         // use internal data for fast requests (up to 0.5 sec.)
340         else if ($key == $this->key && (!$this->vars || $ts - $this->start < 0.5))
341             $oldvars = $this->vars;
342         else // else read data again
343             $oldvars = $this->mc_read($key);
88ca38 344
0d2144 345         $newvars = $oldvars !== null ? $this->_fixvars($vars, $oldvars) : $vars;
88ca38 346
1a8cf6 347         if ($newvars !== $oldvars || $ts - $this->changed > $this->lifetime / 3) {
0d2144 348             return $this->memcache->set($key, serialize(array('changed' => time(), 'ip' => $this->ip, 'vars' => $newvars)),
1a8cf6 349                 MEMCACHE_COMPRESSED, $this->lifetime + 60);
58154f 350         }
0d2144 351
AM 352         return true;
413df0 353     }
58154f 354
cf2da2 355
0d2144 356     /**
AM 357      * Handler for session_destroy() with memcache backend
358      *
359      * @param string Session ID
360      *
361      * @return boolean True on success
362      */
363     public function mc_destroy($key)
364     {
365         if ($key) {
366             // #1488592: use 2nd argument
367             $this->memcache->delete($key, 0);
368         }
369
370         return true;
371     }
cf2da2 372
T 373
0d2144 374     /**
AM 375      * Execute registered garbage collector routines
376      */
3dbe4f 377     public function gc($maxlifetime)
0d2144 378     {
3dbe4f 379         // move gc execution to the script shutdown function
AM 380         // see rcube::shutdown() and rcube_session::write_close()
381         return $this->gc_enabled = $maxlifetime;
0d2144 382     }
cf2da2 383
T 384
0d2144 385     /**
AM 386      * Register additional garbage collector functions
387      *
388      * @param mixed Callback function
389      */
390     public function register_gc_handler($func)
391     {
392         foreach ($this->gc_handlers as $handler) {
393             if ($handler == $func) {
394                 return;
395             }
396         }
0c2596 397
0d2144 398         $this->gc_handlers[] = $func;
AM 399     }
929a50 400
0d2144 401
AM 402     /**
3dbe4f 403      * Garbage collector handler to run on script shutdown
AM 404      */
405     protected function gc_shutdown()
406     {
407         if ($this->gc_enabled) {
408             // just delete all expired sessions
409             if ($this->storage == 'db') {
34a090 410                 $this->db->query("DELETE FROM {$this->table_name}"
AM 411                     . " WHERE `changed` < " . $this->db->now(-$this->gc_enabled));
3dbe4f 412             }
AM 413
414             foreach ($this->gc_handlers as $fct) {
415                 call_user_func($fct);
416             }
417         }
418     }
419
420
421     /**
0d2144 422      * Generate and set new session id
AM 423      *
424      * @param boolean $destroy If enabled the current session will be destroyed
425      */
426     public function regenerate_id($destroy=true)
427     {
428         session_regenerate_id($destroy);
429
430         $this->vars = null;
431         $this->key  = session_id();
432
433         return true;
434     }
435
436
437     /**
f0a715 438      * Append the given value to the certain node in the session data array
TB 439      *
440      * @param string Path denoting the session variable where to append the value
441      * @param string Key name under which to append the new value (use null for appending to an indexed list)
442      * @param mixed  Value to append to the session data array
443      */
444     public function append($path, $key, $value)
445     {
446         // re-read session data from DB because it might be outdated
447         if (!$this->reloaded && microtime(true) - $this->start > 0.5) {
448             $this->reload();
449             $this->reloaded = true;
450             $this->start = microtime(true);
451         }
452
453         $node = &$this->get_node(explode('.', $path), $_SESSION);
454
1e7d12 455         if ($key !== null) {
TB 456             $node[$key] = $value;
457             $path .= '.' . $key;
458         }
459         else {
460             $node[] = $value;
461         }
462
463         $this->appends[] = $path;
464
465         // when overwriting a previously unset variable
466         if ($this->unsets[$path])
467             unset($this->unsets[$path]);
f0a715 468     }
TB 469
470
471     /**
0d2144 472      * Unset a session variable
AM 473      *
390626 474      * @param string Variable name (can be a path denoting a certain node in the session array, e.g. compose.attachments.5)
0d2144 475      * @return boolean True on success
AM 476      */
477     public function remove($var=null)
478     {
479         if (empty($var)) {
480             return $this->destroy(session_id());
481         }
482
483         $this->unsets[] = $var;
f0a715 484
4034a7 485         if (isset($_SESSION[$var])) {
f60388 486             unset($_SESSION[$var]);
4034a7 487         }
TB 488         else {
489             $path = explode('.', $var);
490             $key = array_pop($path);
491             $node = &$this->get_node($path, $_SESSION);
492             unset($node[$key]);
493         }
0d2144 494
AM 495         return true;
496     }
497
498
499     /**
500      * Kill this session
501      */
502     public function kill()
503     {
504         $this->vars = null;
4d480b 505         $this->ip = rcube_utils::remote_addr(); // update IP (might have changed)
0d2144 506         $this->destroy(session_id());
AM 507         rcube_utils::setcookie($this->cookiename, '-del-', time() - 60);
508     }
509
510
511     /**
512      * Re-read session data from storage backend
513      */
514     public function reload()
515     {
1e7d12 516         // collect updated data from previous appends
TB 517         $merge_data = array();
518         foreach ((array)$this->appends as $var) {
519             $path = explode('.', $var);
520             $value = $this->get_node($path, $_SESSION);
521             $k = array_pop($path);
522             $node = &$this->get_node($path, $merge_data);
523             $node[$k] = $value;
524         }
525
0d2144 526         if ($this->key && $this->memcache)
AM 527             $data = $this->mc_read($this->key);
528         else if ($this->key)
529             $data = $this->db_read($this->key);
530
1e7d12 531         if ($data) {
0d2144 532             session_decode($data);
1e7d12 533
TB 534             // apply appends and unsets to reloaded data
535             $_SESSION = array_merge_recursive($_SESSION, $merge_data);
536
537             foreach ((array)$this->unsets as $var) {
538                 if (isset($_SESSION[$var])) {
539                     unset($_SESSION[$var]);
540                 }
541                 else {
542                     $path = explode('.', $var);
543                     $k = array_pop($path);
544                     $node = &$this->get_node($path, $_SESSION);
545                     unset($node[$k]);
546                 }
547             }
548         }
549
0d2144 550     }
AM 551
f0a715 552     /**
TB 553      * Returns a reference to the node in data array referenced by the given path.
554      * e.g. ['compose','attachments'] will return $_SESSION['compose']['attachments']
555      */
556     private function &get_node($path, &$data_arr)
557     {
558         $node = &$data_arr;
559         if (!empty($path)) {
560             foreach ((array)$path as $key) {
561                 if (!isset($node[$key]))
562                     $node[$key] = array();
563                 $node = &$node[$key];
564             }
565         }
566
567         return $node;
568     }
0d2144 569
AM 570     /**
571      * Serialize session data
572      */
573     private function serialize($vars)
574     {
575         $data = '';
576         if (is_array($vars)) {
577             foreach ($vars as $var=>$value)
578                 $data .= $var.'|'.serialize($value);
579         }
580         else {
581             $data = 'b:0;';
582         }
583
584         return $data;
585     }
586
587
588     /**
589      * Unserialize session data
590      * http://www.php.net/manual/en/function.session-decode.php#56106
591      */
592     private function unserialize($str)
593     {
594         $str    = (string)$str;
595         $endptr = strlen($str);
596         $p      = 0;
597
598         $serialized = '';
599         $items      = 0;
600         $level      = 0;
601
602         while ($p < $endptr) {
603             $q = $p;
604             while ($str[$q] != '|')
605                 if (++$q >= $endptr)
606                     break 2;
607
608             if ($str[$p] == '!') {
609                 $p++;
610                 $has_value = false;
611             }
612             else {
613                 $has_value = true;
614             }
615
616             $name = substr($str, $p, $q - $p);
617             $q++;
618
619             $serialized .= 's:' . strlen($name) . ':"' . $name . '";';
620
621             if ($has_value) {
622                 for (;;) {
623                     $p = $q;
624                     switch (strtolower($str[$q])) {
625                     case 'n': // null
626                     case 'b': // boolean
627                     case 'i': // integer
628                     case 'd': // decimal
629                         do $q++;
630                         while ( ($q < $endptr) && ($str[$q] != ';') );
631                         $q++;
632                         $serialized .= substr($str, $p, $q - $p);
633                         if ($level == 0)
634                             break 2;
635                         break;
636                     case 'r': // reference
637                         $q+= 2;
638                         for ($id = ''; ($q < $endptr) && ($str[$q] != ';'); $q++)
639                             $id .= $str[$q];
640                         $q++;
641                         // increment pointer because of outer array
642                         $serialized .= 'R:' . ($id + 1) . ';';
643                         if ($level == 0)
644                             break 2;
645                         break;
646                     case 's': // string
647                         $q+=2;
648                         for ($length=''; ($q < $endptr) && ($str[$q] != ':'); $q++)
649                             $length .= $str[$q];
650                         $q+=2;
651                         $q+= (int)$length + 2;
652                         $serialized .= substr($str, $p, $q - $p);
653                         if ($level == 0)
654                             break 2;
655                         break;
656                     case 'a': // array
657                     case 'o': // object
658                         do $q++;
659                         while ($q < $endptr && $str[$q] != '{');
660                         $q++;
661                         $level++;
662                         $serialized .= substr($str, $p, $q - $p);
663                         break;
664                     case '}': // end of array|object
665                         $q++;
666                         $serialized .= substr($str, $p, $q - $p);
667                         if (--$level == 0)
668                             break 2;
669                         break;
670                     default:
671                         return false;
672                     }
673                 }
674             }
675             else {
676                 $serialized .= 'N;';
677                 $q += 2;
678             }
679             $items++;
680             $p = $q;
681         }
682
683         return unserialize( 'a:' . $items . ':{' . $serialized . '}' );
684     }
685
686
687     /**
688      * Setter for session lifetime
689      */
690     public function set_lifetime($lifetime)
691     {
692         $this->lifetime = max(120, $lifetime);
693
694         // valid time range is now - 1/2 lifetime to now + 1/2 lifetime
695         $now = time();
696         $this->now = $now - ($now % ($this->lifetime / 2));
697     }
698
699
700     /**
701      * Getter for remote IP saved with this session
702      */
703     public function get_ip()
704     {
705         return $this->ip;
706     }
707
708
709     /**
710      * Setter for cookie encryption secret
711      */
712     function set_secret($secret)
713     {
714         $this->secret = $secret;
715     }
716
717
718     /**
719      * Enable/disable IP check
720      */
721     function set_ip_check($check)
722     {
723         $this->ip_check = $check;
724     }
725
726
727     /**
728      * Setter for the cookie name used for session cookie
729      */
730     function set_cookiename($cookiename)
731     {
732         if ($cookiename) {
733             $this->cookiename = $cookiename;
734         }
14291c 735     }
TB 736
737
738     /**
0d2144 739      * Check session authentication cookie
AM 740      *
741      * @return boolean True if valid, False if not
742      */
743     function check_auth()
744     {
745         $this->cookie = $_COOKIE[$this->cookiename];
4d480b 746         $result = $this->ip_check ? rcube_utils::remote_addr() == $this->ip : true;
0d2144 747
AM 748         if (!$result) {
4d480b 749             $this->log("IP check failed for " . $this->key . "; expected " . $this->ip . "; got " . rcube_utils::remote_addr());
0d2144 750         }
AM 751
752         if ($result && $this->_mkcookie($this->now) != $this->cookie) {
753             $this->log("Session auth check failed for " . $this->key . "; timeslot = " . date('Y-m-d H:i:s', $this->now));
754             $result = false;
755
756             // Check if using id from a previous time slot
757             for ($i = 1; $i <= 2; $i++) {
758                 $prev = $this->now - ($this->lifetime / 2) * $i;
759                 if ($this->_mkcookie($prev) == $this->cookie) {
760                     $this->log("Send new auth cookie for " . $this->key . ": " . $this->cookie);
761                     $this->set_auth_cookie();
762                     $result = true;
763                 }
764             }
765         }
766
767         if (!$result) {
768             $this->log("Session authentication failed for " . $this->key
769                 . "; invalid auth cookie sent; timeslot = " . date('Y-m-d H:i:s', $prev));
770         }
771
772         return $result;
773     }
774
775
776     /**
777      * Set session authentication cookie
778      */
779     function set_auth_cookie()
780     {
781         $this->cookie = $this->_mkcookie($this->now);
782         rcube_utils::setcookie($this->cookiename, $this->cookie, 0);
783         $_COOKIE[$this->cookiename] = $this->cookie;
784     }
785
786
787     /**
788      * Create session cookie from session data
789      *
790      * @param int Time slot to use
791      */
792     function _mkcookie($timeslot)
793     {
794         $auth_string = "$this->key,$this->secret,$timeslot";
795         return "S" . (function_exists('sha1') ? sha1($auth_string) : md5($auth_string));
796     }
797
798     /**
799      * Writes debug information to the log
800      */
801     function log($line)
802     {
803         if ($this->logging) {
804             rcube::write_log('session', $line);
805         }
806     }
929a50 807 }