giovannedev / redis-session-handler
Redis会话处理程序的替代方案
Requires
- php: >=5.6
- ext-redis: *
- symfony/polyfill-php70: ^1.19
Requires (Dev)
- guzzlehttp/guzzle: ^6.5
- phpunit/phpunit: ^7.0
- symfony/polyfill-php72: ^1.22
This package is auto-updated.
Last update: 2024-09-29 01:02:28 UTC
README
具有会话锁定和会话固定保护功能的Redis会话处理程序的替代方案。
新闻
- phpredis v4.1.0(发布于2018-07-10)添加了对会话锁定的支持,但默认情况下是禁用的。要启用它,必须将新的INI指令
redis.session.locking_enabled
设置为true
。这个版本是第一个通过ConcurrentTest
测试的版本,该测试对锁定机制施加压力。
安装
RedisSessionHandler需要PHP >=5.6,并启用了phpredis扩展,以及Redis >=2.6端点。将uma/redis-session-handler
添加到composer.json
文件中
$ composer require uma/redis-session-handler
在您的应用程序调用任何session_
函数之前,用UMA\RedisSessionHandler
覆盖默认的会话处理程序。如果您使用的是框架,并且不确定何时或在哪里发生这种情况,一个很好的经验法则是“尽可能早”。一个安全的赌注可能是项目的公开目录中的前端控制器或等效的初始化脚本。
// top of my-project/web/app.php require_once __DIR__ . '/../vendor/autoload.php'; session_set_save_handler(new \UMA\RedisSessionHandler(), true);
请注意,调用session_set_save_handler
会覆盖您可能在php.ini文件中的session.save_handler
选项中设置的任何值,因此您不需要更改该选项。然而,RedisSessionHandler仍然使用session.save_path
来找到Redis服务器,就像原生的phpredis会话处理程序一样。
; examples session.save_path = "localhost" session.save_path = "localhost?timeout=2.5" session.save_path = "tcp://1.2.3.4:5678?prefix=APP_SESSIONS:&database=2" session.save_path = "unix:///var/run/redis.sock" session.save_path = "/var/run/redis.sock?database=2"
可用的查询参数
timeout
(浮点数),默认0.0
,表示无限期超时prefix
(字符串),默认'PHPREDIS_SESSION:'
auth
(字符串),默认null
database
(整数),默认0
目前仅支持单个主机定义。
请注意,当通过Unix套接字连接时,将忽略超时。
已知问题
不建议与将max_execution_time
指令设置为0
的RedisSessionHandler一起使用
每次可能的情况下,处理程序都会使用max_execution_time
指令作为会话锁的硬超时。这是一个最后的手段机制,即使PHP进程崩溃且处理程序未能释放锁,也可以释放会话锁。
当将max_execution_time
设置为0
(表示没有最大执行时间)时,这种类型的硬超时无法使用,因为锁必须保持与运行脚本所需的时间一样长,这是一个未知的时间。这意味着如果由于某种意外原因PHP进程崩溃且处理程序未能释放锁,将没有安全网,您将最终得到一个悬挂的锁,您必须通过其他方式检测和清除它。
因此,当使用RedisSessionHandler时,建议不要禁用max_execution_time
。
RedisSessionHandler不支持session.use_trans_sid=1
也不支持session.use_cookies=0
当这些指令以这种方式设置时,PHP会从使用cookie切换到通过查询参数传递会话ID。
RedisSessionHandler不能以这种方式工作。这是有意为之。
RedisSessionHandler忽略session.use_strict_mode
指令
因为禁用严格模式(默认情况下)运行PHP没有任何意义。RedisSessionHandler仅在严格模式下工作。本README中的会话固定部分解释了这意味着什么。
动机
与 phpredis 一起捆绑的 Redis 会话处理器存在几个相当严重的缺陷多年,具体包括会话锁定缺失和无法抵御会话固定攻击。
本软件包提供了一个基于 Redis 扩展的兼容会话处理器,不受这些问题的影响。
会话锁定解释
在 PHP 的上下文中,“会话锁定”意味着当多个具有相同会话 ID 的请求几乎同时击中服务器时,只有一个可以运行,而其他则被困在 session_start()
中的等待。只有当第一个请求完成或显式运行 session_write_close()
时,其他请求中的一个才能继续。
当一个会话处理器没有实现会话锁定并发时,在高流量下可能会出现并发错误。我将使用默认的 phpredis 处理器和此简单脚本展示这个问题。
<?php // a script that returns the total number of // requests made during a given session's lifecycle. session_start(); if (!isset($_SESSION['visits'])) { $_SESSION['visits'] = 0; } $_SESSION['visits']++; echo $_SESSION['visits'];
首先,我们发送一个请求,它将设置一个新的会话。然后我们使用返回在 Set-Cookie
标题中的会话 ID 发送200个并发、认证的请求。
$ http localhost/visit-counter.php HTTP/1.1 200 OK Cache-Control: no-store, no-cache, must-revalidate Connection: keep-alive Content-Type: text/html; charset=UTF-8 Date: Mon, 23 Jan 2017 12:30:17 GMT Expires: Thu, 19 Nov 1981 08:52:00 GMT Pragma: no-cache Server: nginx/1.11.8 Set-Cookie: PHPSESSID=9mcjmlsh9gp0conq7i5rci7is8gfn6s0gh8r3eub3qpac09gnh21; path=/; HttpOnly Transfer-Encoding: chunked 1 $ hey -n 200 -H "Cookie: PHPSESSID=9mcjmlsh9gp0conq7i5rci7is8gfn6s0gh8r3eub3qpac09gnh21;" https:///visit-counter.php All requests done. Summary: Total: 0.4033 secs Slowest: 0.1737 secs Fastest: 0.0086 secs Average: 0.0805 secs Requests/sec: 495.8509 Status code distribution: [200] 200 responses
从外部看一切看起来都很正常,我们得到了预期的200个OK响应,但如果我们查看 Redis 数据库,我们会看到计数器远远不准确。我们看到的是比201次访问低得多的随机数。
127.0.0.1:6379> KEYS *
1) "PHPREDIS_SESSION:9mcjmlsh9gp0conq7i5rci7is8gfn6s0gh8r3eub3qpac09gnh21"
127.0.0.1:6379> GET PHPREDIS_SESSION:9mcjmlsh9gp0conq7i5rci7is8gfn6s0gh8r3eub3qpac09gnh21
"visits|i:134;"
查看 Redis 的 MONITOR
输出,我们可以看到在重负载下,Redis 经常连续执行两个或多个 GET
命令,因此返回给两个或更多不同请求相同的访问次数。当这种情况发生时,所有那些不幸的请求都会将相同的访问次数返回给 Redis,因此其中一些最终会丢失。例如,在这段日志摘录中,您可以看到第130个请求没有被计算在内。
1485174643.241711 [0 172.21.0.2:49780] "GET" "PHPREDIS_SESSION:9mcjmlsh9gp0conq7i5rci7is8gfn6s0gh8r3eub3qpac09gnh21"
1485174643.241891 [0 172.21.0.2:49782] "GET" "PHPREDIS_SESSION:9mcjmlsh9gp0conq7i5rci7is8gfn6s0gh8r3eub3qpac09gnh21"
1485174643.242444 [0 172.21.0.2:49782] "SETEX" "PHPREDIS_SESSION:9mcjmlsh9gp0conq7i5rci7is8gfn6s0gh8r3eub3qpac09gnh21" "900" "visits|i:129;"
1485174643.242878 [0 172.21.0.2:49780] "SETEX" "PHPREDIS_SESSION:9mcjmlsh9gp0conq7i5rci7is8gfn6s0gh8r3eub3qpac09gnh21" "900" "visits|i:129;"
1485174643.244780 [0 172.21.0.2:49784] "GET" "PHPREDIS_SESSION:9mcjmlsh9gp0conq7i5rci7is8gfn6s0gh8r3eub3qpac09gnh21"
1485174643.245385 [0 172.21.0.2:49784] "SETEX" "PHPREDIS_SESSION:9mcjmlsh9gp0conq7i5rci7is8gfn6s0gh8r3eub3qpac09gnh21" "900" "visits|i:130;"
RedisSessionHandler 通过为每个会话创建一个“锁定”条目来解决此问题,这个条目一次只能由一个执行线程创建。
会话固定解释
会话固定是指 HTTP 客户端可以选择自己的会话 ID 的能力。当允许客户端选择自己的会话 ID 时,恶意攻击者可能会诱骗其他客户端使用他已知的 ID,然后等待他们登录并劫持他们的会话。
从 PHP 5.5.2 开始,有一个名为 session.use_strict_mode
的 INI 指令来保护 PHP 应用程序免受此类攻击。当启用“严格模式”且接收到未知的会话 ID 时,PHP 应该忽略它并生成一个新的,就像它根本没收到一样。不幸的是,phpredis 处理器忽略了该指令,并始终信任从 HTTP 请求接收到的任何会话 ID。
$ http -v https:///visit-counter.php Cookie:PHPSESSID=madeupkey GET / HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate Connection: keep-alive Cookie: PHPSESSID=madeupkey <---- Host: 127.0.0.1 User-Agent: HTTPie/0.9.6 HTTP/1.1 200 OK Cache-Control: no-store, no-cache, must-revalidate Connection: close Content-type: text/html; charset=UTF-8 Expires: Thu, 19 Nov 1981 08:52:00 GMT Host: 127.0.0.1 Pragma: no-cache 1 $ redis-cli 127.0.0.1:6379> keys * 1) "PHPREDIS_SESSION:madeupkey" 127.0.0.1:6379> GET PHPREDIS_SESSION:madeupkey "visits|i:1;"
因此,RedisSessionHandler 仅在严格模式下工作。它只接受已经在 Redis 存储中的外部会话 ID。
测试
运行测试
为此,您需要 Docker >=1.10 和 docker-compose >=1.8。
要运行集成测试套件,只需输入 composer test
,它将负责安装开发依赖项,设置测试容器并运行测试。
$ composer test Loading composer repositories with package information Installing dependencies (including require-dev) from lock file Nothing to install or update Generating autoload files > docker-compose -f tests/docker-compose.yml up -d tests_redis_1 is up-to-date tests_fpm56_1 is up-to-date tests_fpm71_1 is up-to-date tests_fpm70_1 is up-to-date tests_redis_monitor_1 is up-to-date tests_nginx_1 is up-to-date tests_runner_1 is up-to-date > docker exec -t tests_runner_1 sh -c "TARGET=php56 vendor/bin/phpunit" stty: standard input PHPUnit 6.2.3 by Sebastian Bergmann and contributors. ................ 16 / 16 (100%) Time: 1.39 seconds, Memory: 4.00MB OK (16 tests, 54 assertions) > docker exec -t tests_runner_1 sh -c "TARGET=php70 vendor/bin/phpunit" stty: standard input PHPUnit 6.2.3 by Sebastian Bergmann and contributors. ................ 16 / 16 (100%) Time: 1.29 seconds, Memory: 4.00MB OK (16 tests, 54 assertions) > docker exec -t tests_runner_1 sh -c "TARGET=php71 vendor/bin/phpunit" stty: standard input PHPUnit 6.2.3 by Sebastian Bergmann and contributors. ................ 16 / 16 (100%) Time: 1.08 seconds, Memory: 4.00MB OK (16 tests, 54 assertions)
针对原生 phpredis 处理器运行测试
您可以轻松地针对原生 phpredis 处理器运行相同的测试套件。
要这样做,请注释掉 tests/webroot/visit-counter.php
中启用 RedisSessionHandler 的行,然后 FPM 容器将自动选择 phpredis 保存处理程序(撰写本文时为版本 3.1.2)。
// session_set_save_handler(new \UMA\RedisSessionHandler(), true);
$ composer test Loading composer repositories with package information Installing dependencies (including require-dev) from lock file Nothing to install or update Generating autoload files > docker-compose -f tests/docker-compose.yml up -d tests_redis_1 is up-to-date tests_fpm56_1 is up-to-date tests_fpm71_1 is up-to-date tests_fpm70_1 is up-to-date tests_redis_monitor_1 is up-to-date tests_nginx_1 is up-to-date tests_runner_1 is up-to-date > docker exec -t tests_runner_1 sh -c "TARGET=php56 vendor/bin/phpunit" stty: standard input PHPUnit 6.2.3 by Sebastian Bergmann and contributors. ...FFF..FF...... 16 / 16 (100%) Time: 1.15 seconds, Memory: 4.00MB There were 5 failures: ~~snip~~
手动测试
docker-compose.yml
文件配置为将随机TCP端口暴露到nginx容器的80端口。运行 composer env-up
或 composer test
后,您可以使用 docker ps
查看分配的是哪个端口。有了这些信息,您就可以直接从本地机器使用普通浏览器、cURL、wrk或类似工具直接访问测试web服务器。
根据您的Docker配置,您可能需要将 localhost
替换为实际运行Docker守护进程的虚拟机的IP地址。
$ curl -i localhost:32768/visit-counter.php?with_custom_cookie_params HTTP/1.1 200 OK Server: nginx/1.13.1 Date: Sat, 15 Jul 2017 09:40:25 GMT Content-Type: text/html; charset=UTF-8 Transfer-Encoding: chunked Connection: keep-alive Set-Cookie: PHPSESSID=tqm3akv0t5gdf25lf1gcspn479aa989jp0mltc3nuun2b47c7g10; expires=Sun, 16-Jul-2017 09:40:25 GMT; Max-Age=86400; path=/; secure; HttpOnly Expires: Thu, 19 Nov 1981 08:52:00 GMT Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0 Pragma: no-cache 1