会话和数据持久存储
本文为《PHP经典实例》阅读笔记
前言
随着web应用日渐成熟,“有状态性”成为一个常见的需求,有状态应用已经相当普及,甚至被认为是理所当然的。有状态应用是指:访问者浏览网站时,应用能跟踪记录这个访问者的信息。虽然http被设计为无状态协议,不过PHP提供了一组方便的会话管理函数,使实现有状态应用更方便简单,后文将介绍开发有状态应用时要谨记的一些优秀实践做法。
使用会话跟踪
我们可以使用会话模块来跟踪用户,如下面的一个例子:
session_start();
if (! isset($_SESSION['visit'])){
$_SESSION['visit'] = 0;
}
$_SESSION['visit']++;
echo 'You have visited here '.$_SESSION['visit'].' times.';
会话模块通过向用户发送cookie来跟踪用户,cookie中包含随机生成的session id,且cookie名为PHPSESSID
。如果用户不接受cookie,那么会在URL后加上?PHPSESSID=xxxx(id)
,使之能传递到下一个页面。明显这样的URL并不安全,比如一个用户复制该URL并发送给其他人,那么无意间其他人便会假冒成该用户访问网站,因此默认会禁止这种行为。要启用URL中传递session id的功能,可以在开始会话前使用ini_set('session.use_trans_sid',true)
。
防止会话劫持
为确保攻击者不能访问另一用户的会话,我们可以规定只允许通过cookie传递session id,并生成另外一个会话token通过URL传递。只有包含一个合法session id和合法token才可以访问会话,如下面部分示例代码:
ini_set('session.use_only_cookies', true);
//指定是否在客户端仅仅使用 cookie 来存放会话 ID,启用此设定可以防止有关通过 URL 传递会话 ID 的攻击。
session_start();
$salt = 'YourSpecialValueHere';
$tokenstr = strval(date('W')).$salt;
$token = md5($tokenstr);
if (!isset($_REQUEST['token']) || $_REQUEST['token'] != $token){
// 提示登录
echo "Please login";
exit;
}
$_SESSION['token'] = $token;
output_add_rewrite_var('token', $token);
该例通过将当前周数strval(date('W'))
与变量$salt
连接为一个字符串,创建一个自动移位的token,保证token是不固定的且在一段时间内是合法的。
然后检查请求中的token【$_REQUEST具有$_POST和$_GET的功能,但相对来说会比较慢
】,如果未找到则提示重新登录,找到则将它添加到生成的链接【例如当前页面<a>标签的链接后作为get的参数
】或者表单中【以input隐藏域形式
】,以保证下一次请求能顺利进行。用output_add_rewrite_var()
来实现上述功能。
防止会话固定攻击
为确保应用不会受到会话固定攻击(攻击者强制用户使用一个预定义的会话ID),我们应使用会话cookie但会话标识符不会追加到url中,同时频繁生成新的会话ID。
ini_set('session.use_only_cookies', true);
session_start();
if (!isset($_SESSION['generated'])
|| $_SESSION['generated'] < (time() - 30)) {
session_regenerate_id();
$_SESSION['generated'] = time();
}
该例首先设置会话行为,即只能用cookie存储session id,确保PHP不会注意攻击者放在URL中的session id。
一旦会话开始,设置一个值来记录生成session id的最后时间,定期生成一个新的session id,该例所定时间为30秒,就能大大降低攻击者得到合法session id的几率。
这两种方法结合,基本可以消除会话固定攻击的风险。攻击者很难得到一个合法的session id,因为id会频繁变化,另外由于session id只能在cookie中传递,因此基于url的攻击是不可能的。
在数据库中存储会话
我们可能希望在数据库中存储会话数据而不是在文件中,这时如果多个服务器可以访问同一个数据库,那么会话数据就会镜像到所有web服务器。具体方法便是通过向session_set_save_handler()
提供一个实现SessionHandlerInterface
接口的实例,来注册自定义会话存储函数(在PHP 5.4以后的版本才能这样用)。首先我们实现接口如下,其文件名为db.php,它使用PDO将session 数据存储在一个数据库表中:
class DBHandler implements SessionHandlerInterface {
protected $dbh;
/**
* open 回调函数类似于类的构造函数,在会话打开的时候会被调用。
* 这是自动开始会话或者通过调用session_start() 手动开始会话 之后第一个被调用的回调函数。
* 此回调函数操作成功返回 TRUE,反之返回 FALSE。
*/
public function open($save_path, $name) {
try {
$this->connect($save_path, $name);
return true;
} catch (PDOException $e) {
return false;
}
}
/**
* close 回调函数类似于类的析构函数。在 write 回调函数调用之后调用。
* 当调用 session_write_close() 函数之后,也会调用 close 回调函数。
* 此回调函数操作成功返回 TRUE,反之返回 FALSE。
*/
public function close() {
return true;
}
/**
* 销毁session时会调用
* 当调用session_destroy()函数,或者调用session_regenerate_id()函数并且设置 destroy 参数为 TRUE 时,会调用此回调函数。
* 此回调函数操作成功返回 TRUE,反之返回 FALSE。
*/
public function destroy($session_id) {
$sth = $this->dbh->prepare("DELETE FROM sessions WHERE session_id = ?");
$sth->execute(array($session_id));
return true;
}
/**
* 读取session时调用
* 如果会话中有数据,read 回调函数必须返回将会话数据编码(序列化)后的字符串。如果会话中没有数据,read 回调函数返回空字符串。
*
* 在自动开始会话或者通过调用 session_start() 函数手动开始会话之后,PHP内部调用 read 回调函数来获取会话数据。在调用 read 之前,PHP会调用 open 回调函数。
*
* read 回调返回的序列化之后的字符串格式必须与 write 回调函数保存数据时的格式完全一致。 PHP 会自动反序列化返回的字符串并填充 $_SESSION 超级全局变量。 虽然数据看起来和 serialize() 函数很相似, 但是需要提醒的是,它们是不同的。
*/
public function read($session_id) {
$sth = $this->dbh->prepare("SELECT session_data FROM sessions WHERE session_id = ?");
$sth->execute(array($session_id));
$rows = $sth->fetchAll(PDO::FETCH_NUM);
if (count($rows) == 0) {
return '';
} else {
return $rows[0][0];
}
}
/**
* 向数据库中写入数据
*/
public function write($session_id, $session_data) {
$now = time();
$sth = $this->dbh->prepare("UPDATE sessions SET session_data = ?,last_update = ? WHERE session_id = ?");
$sth->execute(array($session_data, $now, $session_id));
if ($sth->rowCount() == 0) {
$sth2 = $this->dbh->prepare('INSERT INTO sessions (session_id,session_data, last_update) VALUES (?,?,?)');
$sth2->execute(array($session_id, $session_data, $now));
}
}
/**
* 建表
*/
public function createTable($save_path, $name, $connect = true) {
if ($connect) {
$this->connect($save_path, $name);
}
$sql=<<<_SQL_
CREATE TABLE sessions (
session_id VARCHAR(64) NOT NULL,
session_data MEDIUMTEXT NOT NULL,
last_update TIMESTAMP NOT NULL,
PRIMARY KEY (session_id)
)
_SQL_;
$this->dbh->exec($sql);
}
/**
* 连接数据库
*/
protected function connect($save_path) {
/* 在DSN中查找作为“查询字符串”参数的用户和密码 */
$parts = parse_url($save_path);
if (isset($parts['query'])) {
parse_str($parts['query'], $query);
$user = isset($query['user']) ? $query['user'] : null;
$password = isset($query['password']) ? $query['password'] : null;
$dsn = $parts['scheme'] . ':';
if (isset($parts['host'])) {
$dsn .= '//' . $parts['host'];
}
$dsn .= $parts['path'];
$this->dbh = new PDO($dsn, $user, $password);
} else {
$this->dbh = new PDO($save_path);
}
$this->dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// 创建会话表的方法(使用异常处理)
try {
$this->dbh->query('SELECT 1 FROM sessions LIMIT 1');
} catch (Exception $e) {
$this->createTable($save_path, NULL, false);
}
}
}
接下来演示如何将该类与session_set_save_handler()
结合,实现在数据库中存储session数据。
include __DIR__ . '/db.php';
ini_set('session.save_path', 'sqlite:/tmp/sessions.db');
session_set_save_handler(new DBHandler);
session_start();
if (! isset($_SESSION['visits'])) {
$_SESSION['visits'] = 0;
}
$_SESSION['visits']++;
print 'You have visited here '.$_SESSION['visits'].' times.';
这个代码块假设与db.php在同一目录中,一旦将session.save_path
设置为指定的PDO DSN,只需要session_set_save_handler(new DBHandler);
就可以将PHP与这个程序关联起来。在此之后,使用会话的代码与使用PHP默认处理程序的代码是一样的。
关于以上的函数讲的并不全面,推荐到 http://php.net/ 去查看详情。