uma/redis-session-handler

Redis会话处理程序的替代方案

v0.9.9 2021-12-12 19:37 UTC

This package is auto-updated.

Last update: 2024-09-18 05:03:11 UTC


README

Build Status Latest Stable Version Monthly Downloads

一个具有会话锁定和会话固定保护功能的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套接字连接时,超时将被忽略。

已知问题

不推荐使用RedisSessionHandler时,将max_execution_time指令设置为0

每当它能够时,处理程序都会使用max_execution_time指令作为会话锁的硬超时。这是一种最后手段机制,即使在PHP进程崩溃且处理程序无法自行释放锁的情况下,也可以释放会话锁。

当将max_execution_time设置为0(表示没有最大执行时间)时,这种硬超时无法使用,因为锁必须保持到脚本运行完毕,这是一个未知的时间。这意味着如果由于某种意外原因PHP进程崩溃且处理程序未能释放锁,将没有安全网,您将不得不通过其他方式检测并清除悬挂的锁。

因此,当使用RedisSessionHandler时,建议您不要禁用max_execution_time

RedisSessionHandler不支持session.use_trans_sid=1session.use_cookies=0

当这些指令以此方式设置时,PHP会从使用cookie切换到将会话ID作为查询参数传递。

RedisSessionHandler不能在这种模式下工作。这是设计上的。

RedisSessionHandler忽略session.use_strict_mode指令

因为以禁用严格模式(默认情况)运行PHP完全没有意义。RedisSessionHandler仅在严格模式下工作。本README中的会话固定部分解释了这意味着什么。

动机

与phpredis(https://github.com/phpredis/phpredis)捆绑的Redis会话处理程序多年来存在一些相当严重的错误,即缺乏会话级别的锁定(https://github.com/phpredis/phpredis/issues/37)和无法防止会话固定攻击(https://github.com/phpredis/phpredis/issues/1033)。

此软件包提供了一种兼容的会话处理程序,它基于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通过为每个会话创建一个“锁定”条目来解决这个问题,这个条目一次只能由一个执行线程创建。

会话固定解释

会话固定(https://www.owasp.org/index.php/Session_fixation)是指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-upcomposer 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