会话和数据持久存储

本文为《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/ 去查看详情。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,423评论 6 491
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,147评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,019评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,443评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,535评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,798评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,941评论 3 407
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,704评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,152评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,494评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,629评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,295评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,901评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,742评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,978评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,333评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,499评论 2 348

推荐阅读更多精彩内容