由于 session 处理程序的生命周期相当复杂,我发现仅用文字解释很难理解 - 所以,我跟踪了对自定义 SessionHandler 的函数调用,并创建了这个关于在调用各种 session 方法时确切发生了什么的概述
https://gist.github.com/mindplay-dk/623bdd50c1b4c0553cd3
我希望这将使实现自定义 SessionHandler 并首次将其正确完成变得容易得多 :-)
(PHP 5 >= 5.4.0, PHP 7, PHP 8)
SessionHandler 是一个特殊的类,可用于通过继承公开当前内部 PHP 会话保存处理程序。有七个方法包装了七个内部会话保存处理程序回调 (open
, close
, read
, write
, destroy
, gc
和 create_sid
)。默认情况下,此类将包装由 session.save_handler 配置指令定义的任何内部保存处理程序,该指令通常默认情况下为 files
。其他内部会话保存处理程序由 PHP 扩展提供,例如 SQLite(作为 sqlite
)、Memcache(作为 memcache
)和 Memcached(作为 memcached
)。
当一个简单的 SessionHandler 实例使用 session_set_save_handler() 设置为保存处理程序时,它将包装当前保存处理程序。从 SessionHandler 扩展的类允许您覆盖方法或通过调用最终包装内部 PHP 会话处理程序的父类方法来拦截或过滤它们。
例如,这允许您拦截 read
和 write
方法来加密/解密会话数据,然后将结果传递到父类和从父类传递。或者,可以完全覆盖像垃圾收集回调 gc
这样的方法。
因为 SessionHandler 包装了当前内部保存处理程序方法,所以上述加密示例可以应用于任何内部保存处理程序,而无需了解处理程序的内部机制。
要使用此类,首先使用 session.save_handler 设置要公开的保存处理程序,然后将 SessionHandler 的实例或扩展它的实例传递给 session_set_save-handler().
请注意,此类的回调方法旨在由 PHP 内部调用,而不是从用户空间代码调用。返回值同样由 PHP 内部处理。有关会话工作流程的更多信息,请参阅 session_set_save_handler().
此类旨在公开当前内部 PHP 会话保存处理程序,如果您想编写自己的自定义保存处理程序,请实现 SessionHandlerInterface 接口,而不是从 SessionHandler 扩展。
示例 #1 使用 SessionHandler 为内部 PHP 保存处理程序添加加密。
<?php
/**
* 解密 AES 256
*
* @param data $edata
* @param string $password
* @return 解密后的数据
*/
function decrypt($edata, $password) {
$data = base64_decode($edata);
$salt = substr($data, 0, 16);
$ct = substr($data, 16);
$rounds = 3; // 取决于密钥长度
$data00 = $password.$salt;
$hash = array();
$hash[0] = hash('sha256', $data00, true);
$result = $hash[0];
for ($i = 1; $i < $rounds; $i++) {
$hash[$i] = hash('sha256', $hash[$i - 1].$data00, true);
$result .= $hash[$i];
}
$key = substr($result, 0, 32);
$iv = substr($result, 32,16);
return openssl_decrypt($ct, 'AES-256-CBC', $key, true, $iv);
}
/**
* 加密 AES 256
*
* @param data $data
* @param string $password
* @return base64 加密后的数据
*/
function encrypt($data, $password) {
// 使用 random_bytes() 生成密码学安全的随机盐
$salt = random_bytes(16);
$salted = '';
$dx = '';
// 对密钥 (32) 和 iv (16) 进行加盐 = 48
while (strlen($salted) < 48) {
$dx = hash('sha256', $dx.$password.$salt, true);
$salted .= $dx;
}
$key = substr($salted, 0, 32);
$iv = substr($salted, 32,16);
$encrypted_data = openssl_encrypt($data, 'AES-256-CBC', $key, true, $iv);
return base64_encode($salt . $encrypted_data);
}
class EncryptedSessionHandler extends SessionHandler
{
private $key;
public function __construct($key)
{
$this->key = $key;
}
public function read($id)
{
$data = parent::read($id);
if (!$data) {
return "";
} else {
return decrypt($data, $this->key);
}
}
public function write($id, $data)
{
$data = encrypt($data, $this->key);
return parent::write($id, $data);
}
}
// 我们将拦截本机 'files' 处理程序,但同样适用于其他内部本机处理程序,如 'sqlite'、'memcache' 或 'memcached'
// 这些处理程序由 PHP 扩展提供。
ini_set('session.save_handler', 'files');
$key = 'secret_string';
$handler = new EncryptedSessionHandler($key);
session_set_save_handler($handler, true);
session_start();
// 继续通过 $_SESSION 中的键设置和检索值
注意:
由于此类的 method 旨在由 PHP 在正常 session 工作流程中内部调用,因此子类对父类 method 的调用(即实际的内部本机处理程序)将返回
false
,除非 session 实际上已启动(自动启动,或通过显式 session_start() 启动)。在编写单元测试时,这很重要,因为在单元测试中,类 method 可能会被手动调用。
由于 session 处理程序的生命周期相当复杂,我发现仅用文字解释很难理解 - 所以,我跟踪了对自定义 SessionHandler 的函数调用,并创建了这个关于在调用各种 session 方法时确切发生了什么的概述
https://gist.github.com/mindplay-dk/623bdd50c1b4c0553cd3
我希望这将使实现自定义 SessionHandler 并首次将其正确完成变得容易得多 :-)
如果您计划实现自己的 SessionHandler,例如使用数据库系统,请确保您的 'create_sid' 方法在数据库中创建一条新记录,使用创建的新会话 ID,并将 'data' 设置为空字符串 '',否则当调用 'read' 方法时(无论是否为全新的会话都会调用),您将收到错误,因为还没有该会话 ID 的记录。有趣的是,您收到的错误会让人感觉 PHP 试图在您的本地驱动器上打开会话(来自您的 .ini 文件)。
您的自定义会话处理程序不应该包含对任何会话函数的调用,例如 session_name() 或 session_id(),因为相关值作为参数传递给各种处理程序方法。尝试从其他来源获取值可能不会按预期工作。
设置自己的会话处理程序的最佳方法是扩展本机 SessionHandler(根据您的需要覆盖 7 种方法 + 构造函数,保持相同的签名)。或者,如果您计划使用 'lazy_write',您还可以实现 SessionUpdateTimestampHandlerInterface
选项 1
class MyOwnSessionHandler extends \SessionHandler { ..... }
选项 2
class MyOwnSessionHandler extends \SessionHandler implements \SessionUpdateTimestampHandlerInterface { ..... }
我不建议这样做
class MyOwnSessionHandler implements \SessionHandlerInterface, \SessionIdInterface, \SessionUpdateTimestampHandlerInterface { ... }
如果您好奇,以下是按顺序调用的方法(使用 Windows 11 64 位上的 XAMPP v3.3.0 上的 PHP 8.2)
- open(始终调用)
- validateId 和/或 create_sid
如果您实现 SessionUpdateTimestampHandlerInterface,则会调用 validateId。如果验证失败,则会调用 create_sid。
如果需要新的会话 ID,则会调用 create_sid:新的会话等。
- read(始终调用)
- write 或 updateTimestamp 或 destroy
如果您调用 'destroy',则不会调用 'write' 或 'updateTimestamp',
如果您启用了 'lazy_write' 并实现了 SessionUpdateTimestampHandlerInterface,
则如果没有任何更改,将调用 'updateTimestamp' 而不是 'write'。
- close(始终调用)
我制作了这个 gist 以提供对 PHP 会话处理程序生命周期的完整概述,更新到 7.0 或更高版本。特别是,我想强调在使用本机 PHP 函数进行会话管理时,调用了哪些方法以及按什么顺序调用。
https://gist.github.com/franksacco/d6e943c41189f8ee306c182bf8f07654
我希望这份分析能够帮助所有有兴趣详细了解 PHP 执行的本机会话管理以及自定义会话处理程序应该做什么的开发人员。
任何评论或建议都表示感谢。
请注意,当您从 \SessionHandler 继承时,您隐式地禁用了会话严格模式,因为它没有实现 SessionUpdateTimestampHandlerInterface(参见:https://php.net/manual/en/session.configuration.php#ini.session.use-strict-mode),因此不会调用 validateId()。
在更改任何权限级别时(例如,在身份验证、密码/权限/用户角色更改期间),调用 session_regenerate_id() 函数至关重要,以减轻会话固定攻击!
这是一个包装器,用于在文件中记录每个会话的操作。有助于调查会话锁定(这会阻止 PHP 为同一个客户端提供同时请求)。
只需更改最后的文件名,就可以将日志转储到您想要的位置。
class DumpSessionHandler extends SessionHandler {
private $fich;
public function __construct($fich) {
$this->fich = $fich;
}
public function close() {
$this->log('close');
return parent::close();
}
public function create_sid() {
$this->log('create_sid');
return parent::create_sid();
}
public function destroy($session_id) {
$this->log('destroy('.$session_id.')');
return parent::destroy($session_id);
}
public function gc($maxlifetime) {
$this->log('close('.$maxlifetime.')');
return parent::gc($maxlifetime);
}
public function open($save_path, $session_name) {
$this->log('open('.$save_path.', '.$session_name.')');
return parent::open($save_path, $session_name);
}
public function read($session_id) {
$this->log('read('.$session_id.')');
return parent::read($session_id);
}
public function write($session_id, $session_data) {
$this->log('write('.$session_id.', '.$session_data.')');
return parent::write($session_id, $session_data);
}
private function log($action) {
$base_uri = explode('?', $_SERVER['REQUEST_URI'], 2)[0];
$hdl = fopen($this->fich, 'a');
fwrite($hdl, date('Y-m-d h:i:s').' '.$base_uri.' : '.$action."\n");
fclose($hdl);
}
}
ini_set('session.save_handler', 'files');
$handler = new DumpSessionHandler('/path/to/dump_sessions.log');
session_set_save_handler($handler, true);
php -S localhost:8000 -t foo/
touch index.php
vi index.php
============================================================
class NativeSessionHandler extends \SessionHandler
{
public function __construct($savePath = null)
{
if (null === $savePath) {
$savePath = ini_get('session.save_path');
}
$baseDir = $savePath;
if ($count = substr_count($savePath, ';')) {
if ($count > 2) {
throw new \InvalidArgumentException(sprintf('Invalid argument $savePath \'%s\'', $savePath));
}
// 最后一个 ';' 后的字符是路径
$baseDir = ltrim(strrchr($savePath, ';'), ';');
}
if ($baseDir && !is_dir($baseDir) && !@mkdir($baseDir, 0777, true) && !is_dir($baseDir)) {
throw new \RuntimeException(sprintf('Session Storage was not able to create directory "%s"', $baseDir));
}
ini_set('session.save_handler', $savePath);
ini_set('session.save_handler', 'files');
}
}
$handler = new NativeSessionHandler("/var/www/foo");
session_set_save_handler($handler, true);
session_start();
$a = $handler->write("aaa","bbbb");var_dump($a);exit;
============================================================
输出:bool(false)