1<?php
2// This file is part of Moodle - http://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
17/**
18 * Database based session handler.
19 *
20 * @package    core
21 * @copyright  2013 Petr Skoda {@link http://skodak.org}
22 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23 */
24
25namespace core\session;
26
27defined('MOODLE_INTERNAL') || die();
28
29/**
30 * Database based session handler.
31 *
32 * @package    core
33 * @copyright  2013 Petr Skoda {@link http://skodak.org}
34 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35 */
36class database extends handler {
37    /** @var \stdClass $record session record */
38    protected $recordid = null;
39
40    /** @var \moodle_database $database session database */
41    protected $database = null;
42
43    /** @var bool $failed session read/init failed, do not write back to DB */
44    protected $failed = false;
45
46    /** @var string $lasthash hash of the session data content */
47    protected $lasthash = null;
48
49    /** @var int $acquiretimeout how long to wait for session lock */
50    protected $acquiretimeout = 120;
51
52    /**
53     * Create new instance of handler.
54     */
55    public function __construct() {
56        global $DB, $CFG;
57        // Note: we store the reference here because we need to modify database in shutdown handler.
58        $this->database = $DB;
59
60        if (!empty($CFG->session_database_acquire_lock_timeout)) {
61            $this->acquiretimeout = (int)$CFG->session_database_acquire_lock_timeout;
62        }
63    }
64
65    /**
66     * Init session handler.
67     */
68    public function init() {
69        if (!$this->database->session_lock_supported()) {
70            throw new exception('sessionhandlerproblem', 'error', '', null, 'Database does not support session locking');
71        }
72
73        $result = session_set_save_handler(array($this, 'handler_open'),
74            array($this, 'handler_close'),
75            array($this, 'handler_read'),
76            array($this, 'handler_write'),
77            array($this, 'handler_destroy'),
78            array($this, 'handler_gc'));
79        if (!$result) {
80            throw new exception('dbsessionhandlerproblem', 'error');
81        }
82    }
83
84    /**
85     * Check the backend contains data for this session id.
86     *
87     * Note: this is intended to be called from manager::session_exists() only.
88     *
89     * @param string $sid
90     * @return bool true if session found.
91     */
92    public function session_exists($sid) {
93        // It was already checked in the calling code that the record in sessions table exists.
94        return true;
95    }
96
97    /**
98     * Kill all active sessions, the core sessions table is
99     * purged afterwards.
100     */
101    public function kill_all_sessions() {
102        // Nothing to do, the sessions table is cleared from core.
103        return;
104    }
105
106    /**
107     * Kill one session, the session record is removed afterwards.
108     * @param string $sid
109     */
110    public function kill_session($sid) {
111        // Nothing to do, the sessions table is purged afterwards.
112        return;
113    }
114
115    /**
116     * Open session handler.
117     *
118     * {@see http://php.net/manual/en/function.session-set-save-handler.php}
119     *
120     * @param string $save_path
121     * @param string $session_name
122     * @return bool success
123     */
124    public function handler_open($save_path, $session_name) {
125        // Note: we use the already open database.
126        return true;
127    }
128
129    /**
130     * Close session handler.
131     *
132     * {@see http://php.net/manual/en/function.session-set-save-handler.php}
133     *
134     * @return bool success
135     */
136    public function handler_close() {
137        if ($this->recordid) {
138            try {
139                $this->database->release_session_lock($this->recordid);
140            } catch (\Exception $ex) {
141                // Ignore any problems.
142            }
143        }
144        $this->recordid = null;
145        $this->lasthash = null;
146        return true;
147    }
148
149    /**
150     * Read session handler.
151     *
152     * {@see http://php.net/manual/en/function.session-set-save-handler.php}
153     *
154     * @param string $sid
155     * @return string
156     */
157    public function handler_read($sid) {
158        try {
159            if (!$record = $this->database->get_record('sessions', array('sid'=>$sid), 'id')) {
160                // Let's cheat and skip locking if this is the first access,
161                // do not create the record here, let the manager do it after session init.
162                $this->failed = false;
163                $this->recordid = null;
164                $this->lasthash = sha1('');
165                return '';
166            }
167            if ($this->recordid and $this->recordid != $record->id) {
168                error_log('Second session read with different record id detected, cannot read session');
169                $this->failed = true;
170                $this->recordid = null;
171                return '';
172            }
173            if (!$this->recordid) {
174                // Lock session if exists and not already locked.
175                if ($this->requires_write_lock()) {
176                    $this->database->get_session_lock($record->id, $this->acquiretimeout);
177                }
178                $this->recordid = $record->id;
179            }
180        } catch (\dml_sessionwait_exception $ex) {
181            // This is a fatal error, better inform users.
182            // It should not happen very often - all pages that need long time to execute
183            // should close session immediately after access control checks.
184            error_log('Cannot obtain session lock for sid: '.$sid);
185            $this->failed = true;
186            throw $ex;
187
188        } catch (\Exception $ex) {
189            // Do not rethrow exceptions here, this should not happen.
190            error_log('Unknown exception when starting database session : '.$sid.' - '.$ex->getMessage());
191            $this->failed = true;
192            $this->recordid = null;
193            return '';
194        }
195
196        // Finally read the full session data because we know we have the lock now.
197        if (!$record = $this->database->get_record('sessions', array('id'=>$record->id), 'id, sessdata')) {
198            // Ignore - something else just deleted the session record.
199            $this->failed = true;
200            $this->recordid = null;
201            return '';
202        }
203        $this->failed = false;
204
205        if (is_null($record->sessdata)) {
206            $data = '';
207            $this->lasthash = sha1('');
208        } else {
209            $data = base64_decode($record->sessdata);
210            $this->lasthash = sha1($record->sessdata);
211        }
212
213        return $data;
214    }
215
216    /**
217     * Write session handler.
218     *
219     * {@see http://php.net/manual/en/function.session-set-save-handler.php}
220     *
221     * NOTE: Do not write to output or throw any exceptions!
222     *       Hopefully the next page is going to display nice error or it recovers...
223     *
224     * @param string $sid
225     * @param string $session_data
226     * @return bool success
227     */
228    public function handler_write($sid, $session_data) {
229        if ($this->failed) {
230            // Do not write anything back - we failed to start the session properly.
231            return false;
232        }
233
234        $sessdata = base64_encode($session_data); // There might be some binary mess :-(
235        $hash = sha1($sessdata);
236
237        if ($hash === $this->lasthash) {
238            return true;
239        }
240
241        try {
242            if ($this->recordid) {
243                $this->database->set_field('sessions', 'sessdata', $sessdata, array('id'=>$this->recordid));
244            } else {
245                // This happens in the first request when session record was just created in manager.
246                $this->database->set_field('sessions', 'sessdata', $sessdata, array('sid'=>$sid));
247            }
248        } catch (\Exception $ex) {
249            // Do not rethrow exceptions here, this should not happen.
250            error_log('Unknown exception when writing database session data : '.$sid.' - '.$ex->getMessage());
251        }
252
253        return true;
254    }
255
256    /**
257     * Destroy session handler.
258     *
259     * {@see http://php.net/manual/en/function.session-set-save-handler.php}
260     *
261     * @param string $sid
262     * @return bool success
263     */
264    public function handler_destroy($sid) {
265        if (!$session = $this->database->get_record('sessions', array('sid'=>$sid), 'id, sid')) {
266            if ($sid == session_id()) {
267                $this->recordid = null;
268                $this->lasthash = null;
269            }
270            return true;
271        }
272
273        if ($this->recordid and $session->id == $this->recordid) {
274            try {
275                $this->database->release_session_lock($this->recordid);
276            } catch (\Exception $ex) {
277                // Ignore problems.
278            }
279            $this->recordid = null;
280            $this->lasthash = null;
281        }
282
283        $this->database->delete_records('sessions', array('id'=>$session->id));
284
285        return true;
286    }
287
288    /**
289     * GC session handler.
290     *
291     * {@see http://php.net/manual/en/function.session-set-save-handler.php}
292     *
293     * @param int $ignored_maxlifetime moodle uses special timeout rules
294     * @return bool success
295     */
296    public function handler_gc($ignored_maxlifetime) {
297        // This should do something only if cron is not running properly...
298        if (!$stalelifetime = ini_get('session.gc_maxlifetime')) {
299            return true;
300        }
301        $params = array('purgebefore' => (time() - $stalelifetime));
302        $this->database->delete_records_select('sessions', 'userid = 0 AND timemodified < :purgebefore', $params);
303        return true;
304    }
305}
306