1: <?php
2:
3: /**
4: * Session handling
5: * @package framework
6: * @subpackage session
7: */
8:
9: /**
10: * This session class uses a PDO compatible DB to manage session data. It does not
11: * use PHP session handlers at all and is a completely indenpendant session system.
12: */
13: class Hm_DB_Session extends Hm_PHP_Session {
14:
15: /* session key */
16: public $session_key = '';
17:
18: /* DB handle */
19: protected $dbh;
20:
21: /*
22: * Database driver type from site config
23: */
24: private $db_driver;
25:
26: /*
27: * Timeout for acquiring locks (seconds)
28: */
29: private $lock_timeout = 10;
30:
31: private $version = 1;
32:
33: /**
34: * Create a new session
35: * @return boolean|integer|array
36: */
37: public function insert_session_row() {
38: return $this->upsert('insert');
39: }
40:
41: /**
42: * Connect to the configured DB
43: * @return bool true on success
44: */
45: public function connect() {
46: $this->db_driver = $this->site_config->get('db_driver', false);
47: return ($this->dbh = Hm_DB::connect($this->site_config)) ? true : false;
48: }
49:
50: /**
51: * Start the session. This could be an existing session or a new login
52: * @param object $request request details
53: * @return void
54: */
55: public function start($request) {
56: $this->db_start($request);
57: }
58:
59: /**
60: * Start a new session
61: * @param object $request request details
62: * @return void
63: */
64: public function start_new($request) {
65: $this->session_key = Hm_Crypt::unique_id();
66: $this->secure_cookie($request, $this->cname, $this->session_key, '', '', 'Lax');
67: if ($this->insert_session_row()) {
68: Hm_Debug::add('LOGGED IN', 'success');
69: $this->active = true;
70: return;
71: }
72: Hm_Debug::add('Failed to start a new session');
73: }
74:
75: /**
76: * Continue an existing session
77: * @param string $key session key
78: * @return void
79: */
80: public function start_existing($key) {
81: $this->session_key = $key;
82: $data = $this->get_session_data($key);
83: if (is_array($data)) {
84: Hm_Debug::add('LOGGED IN', 'success');
85: $this->active = true;
86: $this->data = $data;
87: }
88: }
89:
90: /**
91: * Get session data from the DB
92: * @param string $key session key
93: * @return mixed array results or false on failure
94: */
95: public function get_session_data($key) {
96: $results = Hm_DB::execute($this->dbh, 'select data, hm_version from hm_user_session where hm_id=?', [$key]);
97: if (is_array($results)) {
98: if (array_key_exists('data', $results) && array_key_exists('hm_version', $results)) {
99: $this->version = $results['hm_version'];
100: $data = $results['data'];
101: if (is_resource($data)) {
102: $data = stream_get_contents($data);
103: }
104: return $this->plaintext($data);
105: }
106: }
107: Hm_Debug::add('DB SESSION failed to read session data');
108: return false;
109: }
110:
111: /**
112: * End a session after a page request is complete. This only closes the session and
113: * does not destroy it
114: * @return void
115: */
116: public function end() {
117: if (!$this->session_closed && $this->active) {
118: $this->save_data();
119: }
120: $this->active = false;
121: }
122:
123: /**
124: * Write session data to the db
125: * @return boolean|integer|array
126: */
127: public function save_data() {
128: return $this->upsert('update');
129: }
130:
131: /**
132: * Close a session early, but don't destroy it
133: * @return void
134: */
135: public function close_early() {
136: $this->session_closed = true;
137: $this->save_data();
138: }
139: /**
140: * Update or insert a row
141: * @param string $type type of action (insert or update)
142: * @return boolean|integer|array
143: */
144: public function upsert($type) {
145: $res = false;
146: $params = [':key' => $this->session_key, ':data' => $this->ciphertext($this->data)];
147: if ($type == 'update') {
148: if ($this->version === null) {
149: Hm_Debug::add('DB SESSION: Missing hm_version for session key ' . $this->session_key);
150: return false;
151: }
152: $params[':hm_version'] = $this->version;
153: if (!$this->acquire_lock($this->session_key)) {
154: Hm_Debug::add('Failed to acquire lock on session');
155: return false;
156: }
157: $res = Hm_DB::execute($this->dbh, 'update hm_user_session set data=:data, hm_version=hm_version+1 where hm_id=:key and hm_version=:hm_version', $params);
158: if ($res === 0) {
159: Hm_Debug::add('Optimistic Locking: hm_version mismatch, session data not updated');
160: $this->release_lock($this->session_key);
161: return false;
162: }
163: $this->release_lock($this->session_key);
164: } elseif ($type == 'insert') {
165: $res = Hm_DB::execute($this->dbh, 'insert into hm_user_session (hm_id, data, hm_version, date) values(:key, :data, 1, current_date)', $params);
166: Hm_Debug::add('Session insert params: ' . json_encode($params));
167: }
168: if (!$res) {
169: Hm_Debug::add('DB SESSION failed to write session data');
170: }
171: return $res;
172: }
173:
174: /**
175: * Destroy a session for good
176: * @param object $request request details
177: * @return void
178: */
179: public function destroy($request) {
180: if (Hm_Functions::function_exists('delete_uploaded_files')) {
181: delete_uploaded_files($this);
182: }
183: Hm_DB::execute($this->dbh, 'delete from hm_user_session where hm_id=?', [$this->session_key]);
184: $this->delete_cookie($request, $this->cname);
185: $this->delete_cookie($request, 'hm_id');
186: $this->delete_cookie($request, 'hm_reload_folders');
187: $this->delete_cookie($request, 'hm_msgs');
188: $this->active = false;
189: Hm_Request_Key::load($this, $request, false);
190: }
191:
192: /**
193: * Start the session. This could be an existing session or a new login
194: * @param object $request request details
195: * @return void
196: */
197: public function db_start($request) {
198: if ($this->connect()) {
199: if ($this->loaded) {
200: $this->start_new($request);
201: } elseif (!array_key_exists($this->cname, $request->cookie)) {
202: $this->destroy($request);
203: } else {
204: $this->start_existing($request->cookie[$this->cname]);
205: }
206: }
207: }
208:
209: /**
210: * Acquire a lock for the session (unified for all DB types)
211: * @param string $key session key
212: * @return bool true if lock acquired, false otherwise
213: */
214: private function acquire_lock($key) {
215: $lock_name = 'session_lock_' . substr(hash('sha256', $key), 0, 51);
216: // Polling parameters
217: $max_attempts = 5;
218: $retry_interval = 500000;
219: $attempts = 0;
220: $query = '';
221: $params = [];
222: while ($attempts < $max_attempts) {
223: switch ($this->db_driver) {
224: case 'mysql':
225: $query = 'SELECT GET_LOCK(:lock_name, :timeout)';
226: $params = [':lock_name' => $lock_name, ':timeout' => $this->lock_timeout];
227: break;
228: case 'pgsql':
229: $query = 'SELECT pg_try_advisory_lock(:hash_key)';
230: $params = [':hash_key' => crc32($lock_name)];
231: break;
232: case 'sqlite':
233: $query = 'UPDATE hm_user_session SET lock=1 WHERE hm_id=:hm_id AND lock=0';
234: $params = [':hm_id' => $key];
235: break;
236: default:
237: Hm_Debug::add('DB SESSION: Unsupported db_driver for locking: ' . $this->db_driver);
238: return false;
239: }
240: $result = Hm_DB::execute($this->dbh, $query, $params, ($this->db_driver == 'sqlite') ? 'modify' : false);
241: if ($this->db_driver == 'mysql') {
242: if (isset($result['GET_LOCK(?, ?)']) && $result['GET_LOCK(?, ?)'] == 1) {
243: return true;
244: }
245: }
246: if ($this->db_driver == 'pgsql') {
247: if (isset($result['pg_try_advisory_lock']) && $result['pg_try_advisory_lock'] === true) {
248: return true;
249: }
250: }
251: if ($this->db_driver == 'sqlite') {
252: if ($result >= 1) {
253: return true;
254: }
255: }
256: $attempts++;
257: if ($attempts < $max_attempts) {
258: usleep($retry_interval);
259: }
260: }
261: Hm_Debug::add('DB SESSION: Failed to acquire lock after ' . $max_attempts . ' attempts.');
262: return false;
263: }
264:
265: /**
266: * Release a lock for the session (unified for all DB types)
267: * @param string $key session key
268: * @return bool true if lock released, false otherwise
269: */
270: private function release_lock($key) {
271: $query = '';
272: $params = [];
273: $lock_name = "session_lock_" . substr(hash('sha256', $key), 0, 51);
274: switch ($this->db_driver) {
275: case 'mysql':
276: $query = 'SELECT RELEASE_LOCK(:lock_name)';
277: $params = [':lock_name' => $lock_name];
278: break;
279: case 'pgsql':
280: $query = 'SELECT pg_advisory_unlock(:hash_key)';
281: $params = [':hash_key' => crc32($lock_name)];
282: break;
283: case 'sqlite':
284: $query = 'UPDATE hm_user_session SET lock=0 WHERE hm_id=:hm_id';
285: $params = [':hm_id' => $key];
286: break;
287: default:
288: Hm_Debug::add('DB SESSION: Unsupported db_driver for unlocking: ' . $this->db_driver);
289: return false;
290: }
291: $result = Hm_DB::execute($this->dbh, $query, $params);
292: if ($this->db_driver == 'mysql') {
293: return isset($result['GET_LOCK(?, ?)']) && $result['GET_LOCK(?, ?)'] == 1;
294: }
295: if ($this->db_driver == 'pgsql') {
296: return isset($result['pg_advisory_unlock']) && $result['pg_advisory_unlock'] === true;
297: }
298: if ($this->db_driver == 'sqlite') {
299: return isset($result[0]) && $result[0] == 1;
300: }
301: Hm_Debug::add('DB SESSION: Lock release failed. Query: ' . $query . ' Parameters: ' . json_encode($params));
302: return false;
303: }
304: }
305: