aliene/phalcon-session-redis

Phalcon PHP 的替代 Redis 会话处理器

v0.1.1 2018-07-05 17:21 UTC

This package is not auto-updated.

Last update: 2024-09-21 04:26:51 UTC


README

Build Status Scrutinizer Code Quality Latest Stable Version Monthly Downloads

Phalcon PHP 的一个替代 Redis 会话处理器,具有会话锁和解固定会话保护功能。

安装

PhalconSessionRedis 需要 PHP >=5.6,启用了 phpredis 扩展,并有一个 Redis >=2.6 端点。将 aliene/phalcon-session-redis 添加到 composer.json 文件

$ composer require aliene/phalcon-session-redis

示例

// services.php

require_once __DIR__ . '/../vendor/autoload.php';

$di->set("session", function() {
    $session = new \Aliene\Phalcon\Session\Redis([
        "host" => "localhost",
        "auth" => "password",
        "lifetime" => 3600 // ttl 1 hour
    ]);

    $session->start();
});

可用选项

  • host (字符串) 默认 "localhost"
  • port (整数) 默认 6379
  • lifetime (整数),默认 3600,会话生存期(ttl)
  • prefix (字符串),默认 'SESSIONS:'
  • auth (字符串),默认 null
  • database (整数),默认 0

目前只支持单个主机定义。

已知问题

不推荐将 PhalconSessionRedis 与 max_execution_time 指令设置为 0

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

max_execution_time 设置为 0(表示没有最大执行时间)时,这种硬超时无法使用,因为锁必须保持与运行脚本所需的时间一样长,这是一个未知的时间量。这意味着如果由于某些意外原因 PHP 进程崩溃且处理器无法释放锁,就没有安全网,并且你最终会得到一个悬垂锁,你必须通过其他方式检测并清除。

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

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

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

PhalconSessionRedis 不能在此模式下工作。 这是设计决定的

PhalconSessionRedis 忽略 session.use_strict_mode 指令

因为禁用严格模式(默认情况!)根本没有任何意义。PhalconSessionRedis 只能在严格模式下工作。本 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-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