Skip to content

Commit c5be055

Browse files
authored
Merge pull request #5 from ingenerator/add-mysql-session-handler
Add MySQL session handler
2 parents f558116 + d1d87eb commit c5be055

File tree

5 files changed

+324
-0
lines changed

5 files changed

+324
-0
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
### Unreleased
22

3+
### v0.1.5(2018-08-16)
4+
5+
* Add MysqlSession session handler
6+
37
### v0.1.4 (2018-04-30)
48

59
* Add StrictDate::on_or_after for validating date >= date ignoring any time component

src/Session/MysqlSession.php

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
<?php
2+
/**
3+
* @author Craig Gosman <craig@ingenerator.com>
4+
* @licence proprietary
5+
*/
6+
7+
8+
namespace Ingenerator\PHPUtils\Session;
9+
10+
11+
use PDO;
12+
use SessionHandlerInterface;
13+
14+
class MysqlSession implements SessionHandlerInterface
15+
{
16+
/**
17+
* @var PDO
18+
*/
19+
protected $db;
20+
21+
/**
22+
* @var string
23+
*/
24+
protected $hash_salt;
25+
26+
/**
27+
* @var int
28+
*/
29+
protected $lock_timeout;
30+
31+
/**
32+
* @var int
33+
*/
34+
protected $session_lifetime;
35+
36+
/**
37+
* @var string
38+
*/
39+
protected $session_lock;
40+
41+
/**
42+
* @param PDO $db
43+
* @param string $hash_salt
44+
* @param int $lock_timeout seconds to wait for MySQL lock
45+
*/
46+
public function __construct(PDO $db, $hash_salt, $lock_timeout = 20)
47+
{
48+
$this->db = $db;
49+
$this->hash_salt = $hash_salt;
50+
$this->lock_timeout = $lock_timeout;
51+
$this->session_lifetime = ini_get('session.gc_maxlifetime');
52+
}
53+
54+
/**
55+
* @return void
56+
*/
57+
public function initialise()
58+
{
59+
session_set_save_handler($this, TRUE);
60+
}
61+
62+
/**
63+
* {@inheritdoc}
64+
*/
65+
public function close()
66+
{
67+
return $this->releaseLock();
68+
}
69+
70+
/**
71+
* {@inheritdoc}
72+
*/
73+
public function destroy($id)
74+
{
75+
return $this->db->prepare("DELETE FROM `sessions` WHERE `id` = :id")
76+
->execute(['id' => $id]);
77+
}
78+
79+
/**
80+
* Garbage collects expired sessions based on current session.gc_maxlifetime
81+
*
82+
* @return int
83+
*/
84+
public function garbageCollect()
85+
{
86+
return $this->gc($this->session_lifetime);
87+
}
88+
89+
/**
90+
* {@inheritdoc}
91+
*/
92+
public function gc($maxlifetime)
93+
{
94+
$now = new \DateTimeImmutable();
95+
$gc = $this->db->prepare("DELETE FROM `sessions` WHERE `last_active` < :expire");
96+
$gc->execute(['expire' => $now->sub(new \DateInterval('PT'.$maxlifetime.'S'))->format('Y-m-d H:i:s')]);
97+
98+
return $gc->rowCount();
99+
}
100+
101+
/**
102+
* {@inheritdoc}
103+
*/
104+
public function open($save_path, $name)
105+
{
106+
return TRUE;
107+
}
108+
109+
/**
110+
* {@inheritdoc}
111+
*/
112+
public function read($id)
113+
{
114+
$now = new \DateTimeImmutable();
115+
116+
$this->getLock($id);
117+
118+
$query = $this->db->prepare(
119+
"SELECT `session_data` FROM `sessions` WHERE `id` = :id AND `last_active` > :expire AND `hash` = :hash LIMIT 1"
120+
);
121+
$query->execute(
122+
[
123+
'id' => $id,
124+
'expire' => $now->sub(new \DateInterval('PT'.$this->session_lifetime.'S'))->format('Y-m-d H:i:s'),
125+
'hash' => $this->calculateHash(),
126+
]
127+
);
128+
129+
if ($result = $query->fetchColumn(0)) {
130+
return $result;
131+
}
132+
133+
// on error return an empty string - this HAS to be an empty string
134+
return '';
135+
}
136+
137+
/**
138+
* {@inheritdoc}
139+
*/
140+
public function write($id, $session_data)
141+
{
142+
$user_agent = '';
143+
if (isset($_SERVER['HTTP_USER_AGENT'])) {
144+
$user_agent = $_SERVER['HTTP_USER_AGENT'];
145+
}
146+
147+
$ip = '';
148+
if (isset($_SERVER['REMOTE_ADDR'])) {
149+
$ip = $_SERVER['REMOTE_ADDR'];
150+
}
151+
152+
return $this->db->prepare(
153+
"INSERT INTO `sessions` (`id`, `hash`, `session_data`, `last_active`, `session_start`, `user_agent`, `ip`)
154+
VALUES (:id, :hash, :data, :now, :now, :user_agent, :ip)
155+
ON DUPLICATE KEY UPDATE session_data = :data, last_active = :now, user_agent = :user_agent, ip = :ip"
156+
)->execute(
157+
[
158+
'id' => $id,
159+
'hash' => $this->calculateHash(),
160+
'data' => $session_data,
161+
'now' => date('Y-m-d H:i:s'),
162+
'user_agent' => $user_agent,
163+
'ip' => $ip,
164+
]
165+
);
166+
}
167+
168+
/**
169+
* @return string
170+
*/
171+
protected function calculateHash()
172+
{
173+
$hash = '';
174+
175+
if (isset($_SERVER['HTTP_USER_AGENT'])) {
176+
$hash .= $_SERVER['HTTP_USER_AGENT'];
177+
}
178+
179+
$hash .= $this->hash_salt;
180+
181+
return sha1($hash);
182+
}
183+
184+
/**
185+
* @param $id
186+
*
187+
* @return bool
188+
* @throws \ErrorException
189+
*/
190+
protected function getLock($id)
191+
{
192+
$this->session_lock = 'session_'.$id;
193+
194+
$query = $this->db->prepare("SELECT GET_LOCK(:session_lock, :timeout)");
195+
$query->execute(['session_lock' => $this->session_lock, 'timeout' => $this->lock_timeout]);
196+
$result = $query->fetchColumn(0);
197+
198+
if ($result != 1) {
199+
throw new SessionLockNotObtainedException('Could not obtain session lock!');
200+
}
201+
202+
return TRUE;
203+
}
204+
205+
/**
206+
* @return bool
207+
*/
208+
protected function releaseLock()
209+
{
210+
$query = $this->db->prepare("SELECT RELEASE_LOCK(:session_lock)");
211+
$query->execute(['session_lock' => $this->session_lock]);
212+
$result = $query->fetchColumn(0);
213+
214+
if ($result == 1) {
215+
return TRUE;
216+
}
217+
218+
return FALSE;
219+
}
220+
}

src/Session/README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
MySQL Session Handler
2+
=====================
3+
4+
Setup
5+
-----
6+
7+
```
8+
CREATE TABLE `sessions` (
9+
`id` varchar(40) NOT NULL DEFAULT '',
10+
`hash` varchar(40) NOT NULL DEFAULT '',
11+
`session_data` blob NOT NULL,
12+
`last_active` datetime NOT NULL,
13+
`session_start` datetime NOT NULL,
14+
`user_agent` varchar(255) DEFAULT NULL,
15+
`ip` varchar(15) DEFAULT NULL,
16+
PRIMARY KEY (`id`)
17+
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
18+
```
19+
20+
Set session expiry using `session.gc_maxlifetime` in `php.ini`
21+
22+
Usage
23+
-----
24+
25+
Call `MysqlSession->initialise();` to set this as your session handler
26+
in your bootstrap.
27+
28+
Garbage collection
29+
------------------
30+
31+
PHP does probability based session GC by default. Using
32+
`session.gc_divisor` and `session.gc_probability` in `php.ini` to
33+
control the frequency
34+
35+
Probability based GC works somewhat but it has few problems.
36+
1) Low traffic sites' session data may not be deleted within the
37+
preferred duration.
38+
2) High traffic sites' GC may be too frequent GC.
39+
3) GC is performed on the user's request and the user will experience a
40+
GC delay.
41+
42+
Therefore, it is recommended to execute GC periodically for production
43+
systems using cron. Make sure to disable probability based GC by
44+
setting `session.gc_probability = 0`.
45+
46+
As you can only call [session_gc()](http://php.net/manual/en/function.session-gc.php)
47+
after you have called `session_start()` and you may not want / have a
48+
session handler for PHP CLI you can call `garbageCollect()` on
49+
`MySQLSession` to achieve the same effect without initialising a
50+
session first
51+
52+
A sample cron would look something like:
53+
```
54+
$pdo = new PDO("mysql:host=$servername;dbname=$db", $username, $password);
55+
$session_handler = new Ingenerator\PHPUtils\Session\MysqlSession($pdo, 'insecure-secret');
56+
echo $session_handler->garbageCollect()." sessions were garbage collected".PHP_EOL;
57+
```
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
/**
3+
* @author Craig Gosman <craig@ingenerator.com>
4+
* @licence proprietary
5+
*/
6+
7+
namespace Ingenerator\PHPUtils\Session;
8+
9+
10+
class SessionLockNotObtainedException extends \ErrorException
11+
{
12+
13+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
/**
3+
* @author Craig Gosman <craig@ingenerator.com>
4+
* @licence proprietary
5+
*/
6+
7+
namespace test\unit\Ingenerator\PHPUtils\Session;
8+
9+
10+
use Ingenerator\PHPUtils\Session\MysqlSession;
11+
use PHPUnit\Framework\TestCase;
12+
13+
class MysqlSessionTest extends TestCase
14+
{
15+
16+
public function test_it_is_initialisable()
17+
{
18+
$this->assertInstanceOf(MysqlSession::class, $this->newSubject());
19+
}
20+
21+
protected function newSubject()
22+
{
23+
return new MysqlSession(new PDOMock, 'insecure-salt');
24+
}
25+
26+
}
27+
28+
class PDOMock extends \PDO {
29+
public function __construct() {}
30+
}

0 commit comments

Comments
 (0)