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