会话和数据持久存储

作者: honehou | 来源:发表于2016-11-20 19:54 被阅读57次

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

    相关文章

      网友评论

        本文标题:会话和数据持久存储

        本文链接:https://www.haomeiwen.com/subject/vfeguttx.html