vertilia / valid-array
基于 PHP 标准过滤机制进行自动项目验证的对象
Requires
- php: >=7.4
- ext-filter: *
Requires (Dev)
- phpunit/phpunit: ^9
README
基于本地 php-filter
扩展的数据过滤机制,具有额外功能。
一个 ValidArray
对象在实例化时接收一个关联数组,元素名称作为键,元素过滤器作为值。这些过滤器保证在设置此对象中相应元素时,它们将自动进行验证,并将有效值存储起来。如果元素未通过验证,则可以根据过滤器参数使用默认值。对象可以作为普通数组使用来设置元素和获取元素值,验证在元素修改时进行。
ValidArray
扩展 SPL ArrayObject
并包装了 php-filter
扩展的功能。
简介
使用 PHP 标准的 php-filter
扩展进行原生数据验证是处理数据的一种好方法,可以确保用户输入的正确性并保护免受多种攻击向量。此扩展与 PHP 一起捆绑,在大多数配置中默认可用,这使得它成为任何需要数据验证的项目的一个自然选择。
作为捆绑的扩展,php-filter
从 PHP 手册 中受益于文档覆盖,编译模块的性能提升和几乎是绝对的可用性。
尽管在 PHP 中是事实上的数据验证标准,但它仍然受到早期做出的几个有疑问的设计决策的影响,这些决策可能会在用户领域实施验证策略时减慢学习曲线和开发速度。
其中一些可疑的决策
- 🤨
default
值仅针对有限数量的过滤器实现,仅用于替换不正确格式化的参数,否则将匹配定义的过滤器标志,但不适用于未提供参数或检测到标志不匹配的情况, - 🤔 有限的
FILTER_CALLBACK
功能,当标志被忽略且验证回调无法区分标量数据和数组时, - 🧐 对象可以被过滤,但只有当它们定义了
__toString()
魔法方法(自 v8.0 起实现Stringable
)时,并且在验证后仅保留此值作为元素值。
基于 php-filter
扩展的众多优点,ValidArray
类在可能的情况下纠正了上述缺陷,并将 php-filter
功能实现为有用且可预测的数据结构,具有以下功能
- 具有验证机制的关联数组,
- 在数组实例化和项目修改时提供验证,
- 在数据对象实例化时设置过滤器,
- 适用于输入/输出参数验证,
- 最小占用空间,
- 通过扩展
ArrayObject
允许在用户代码中进行自然数组处理, - 使用标准
php-filter
扩展,并增强了对默认值、回调和对象过滤的处理。
示例
言多必失,让我们看看它是如何工作的。
我们将处理一个带有需要验证的 email
、password
和 uri
参数的登录 POST 请求。
POST /api/login
路由将调用 LoginController
并将请求参数作为一个数组传递,该数组应该有 3 个字段:email
、password
和 uri
。所有字段都应包含有效数据,具体定义如下
email
是一个包含有效电子邮件地址的字符串或false
(如果无效),password
是一个包含发送密码的哈希版本的字符串(在验证过程中计算)或false
(如果无效);有效的密码是一个至少包含 8 个字符的字符串,uri
是一个字符串,包含登录或默认使用#member
后重定向到下一页的路径。
我们应在相应的控制器中识别请求参数,如下所示简化的代码,我们的目标是实现 withRequestVars()
方法。
<?php // router.php // create controller $controller = new ApiLoginController(); // validate request vars $controller->withRequestVars(); // run controller with valid parameters $controller->run();
<?php // ApiLoginController.php class ApiLoginController implements Runnable { protected array $vars = []; public function withRequestVars(): self { // TODO validate request variables $this->vars = []; // <- we need to implement this return $this; } public function run() { // TODO redirect to $uri or output error... } }
使用 php-filter
实现所需的验证(第一次尝试)
<?php // validate request variables $this->vars = filter_input_array( INPUT_POST, [ 'email' => FILTER_VALIDATE_EMAIL, 'password' => [ 'filter' => FILTER_CALLBACK, 'options' => function ($pwd) { return (strlen($pwd) >= 8) ? password_hash($pwd, PASSWORD_BCRYPT) : false; }, ], 'uri' => [ 'filter' => FILTER_DEFAULT, 'options' => ['default' => '#member'], // <- will not work ], ] ) ?: [];
如果您需要使用
php-filter
扩展的帮助,请参阅 官方文档。
现在,这将部分工作。直到有人发现登录 API 并决定通过以下形式的几个绝对合法请求来使我们的系统崩溃(添加换行符以提高可读性)
email=abc@def.com
&uri=%23member
&password[]=12345678
&password[]=12345678
&password[]=12345678
... repeat 100 times
&password[]=12345678
当 php-filter
扩展看到发送的数组时,其默认行为是使用提供的过滤器验证数组中的每个项目,并保持数组结构不变,用过滤后的值替换数组值。在大多数情况下,这可能是一种正确的行为(无论如何,过滤器扩展就是这样工作的),但在我们的情况下,这将开始计算所有提供的密码的密码散列,这是一个非常耗CPU的任务。几个这样的请求并行发送就足以使我们的基础设施陷入困境。
php-filter
对这种情况有一个非常优雅的解决方案,称为过滤器标志,我们的第一个意图是设置 FILTER_REQUIRE_SCALAR
。
设置 FILTER_REQUIRE_SCALAR
标志(第二次尝试)
<?php // validate request variables $this->vars = filter_input_array( INPUT_POST, [ 'email' => [ 'filter' => FILTER_VALIDATE_EMAIL, 'flags' => FILTER_REQUIRE_SCALAR, ], 'password' => [ 'filter' => FILTER_CALLBACK, 'flags' => FILTER_REQUIRE_SCALAR, // <- will not work 'options' => function ($pwd) { return (strlen($pwd) >= 8) ? password_hash($pwd, PASSWORD_BCRYPT) : false; }, ], 'uri' => [ 'filter' => FILTER_DEFAULT, 'flags' => FILTER_REQUIRE_SCALAR, 'options' => ['default' => '#member'], // <- will not work ], ] ) ?: [];
这种方法没有问题,标量标志将很好地用于 email
和 uri
字段,但不能用于 password
字段。是的,FILTER_CALLBACK
按设计简单地 忽略标志。您无法对其进行任何操作,因此在验证步骤之前,您可能希望在捕获 password
参数作为数组传递的可能性之前添加验证。
还有其他一些事情需要您正确处理,那就是通过 default
标志提供的默认值,只有在 参数提供但无效时才使用。它仅适用于 FILTER_VALIDATE_*
过滤器(顺便说一句,不适用于 FILTER_DEFAULT
),如果任何清理过滤器将参数值缩小到空字符串,如果参数值与定义的标志不匹配,或者最后,如果参数未在请求中设置,则不会使用它。
因此,我们的最终版本应处理这些附加条件。
手动处理数组类型和默认值(第三次尝试)
<?php // validate request variables $post = $_POST; if (is_array($post['password'] ?? null)) { $post['password'] = false; } if (empty($post['uri'])) { $post['uri'] = '#member'; } $vars = filter_var_array( $post, [ 'email' => [ 'filter' => FILTER_VALIDATE_EMAIL, 'flags' => FILTER_REQUIRE_SCALAR, ], 'password' => [ 'filter' => FILTER_CALLBACK, 'options' => function ($pwd) { return (strlen($pwd) >= 8) ? password_hash($pwd, PASSWORD_BCRYPT) : false; }, ], 'uri' => [ 'filter' => FILTER_DEFAULT, 'flags' => FILTER_REQUIRE_SCALAR, ], ] ) ?: [];
这绝对不是最漂亮的代码片段,这就是 ValidArray
改善情况的地方。
考虑相同的示例,但使用 ValidArray
功能实现
使用 ValidArray
实现所需的验证
<?php use Vertilia\ValidArray; // validate request variables $this->vars = new ValidArray( [ 'email' => [ 'filter' => FILTER_VALIDATE_EMAIL, 'flags' => FILTER_REQUIRE_SCALAR, ], 'password' => [ 'filter' => ValidArray::FILTER_EXTENDED_CALLBACK, 'flags' => FILTER_REQUIRE_SCALAR, 'options' => [ 'callback' => function ($pwd) { return (strlen($pwd) >= 8) ? password_hash($pwd, PASSWORD_BCRYPT) : false; } ], ], 'uri' => [ 'filter' => FILTER_SANITIZE_STRING, 'flags' => FILTER_REQUIRE_SCALAR, 'options' => ['default' => '#member'], ], ], $_POST );
ValidArray
的特性
ValidArray
和 filter_*()
函数之间的显著区别
ValidArray
是一个对象,但由于扩展了 SPL 类ArrayObject
,允许以数组方式访问元素;- 它为所有过滤器设置默认值或
null
以用于缺失元素; - 它提供
ValidArray::FILTER_EXTENDED_CALLBACK
过滤器,允许定义回调,以解锁flags
和default
功能; - 它提供
ValidArray::FILTER_INSTANCE_OF
过滤器,允许验证值是否为特定类型的对象,并使用flags
和default
功能; ValidArray
总是使用filter_var_array()
函数的$add_empty
模式,并且不允许取消设置具有过滤器定义的元素;- 对
ValidArray
对象的count()
将始终返回定义的过滤器的数量。 - 为未定义的键设置值将被忽略;
- 为现有键设置值将触发一个
filter_var()
调用,可能会以下方式修改值- 更改值(例如:
FILTER_SANITIZE_NUMBER_INT
), - 更改值的类型(例如:
FILTER_VALIDATE_INT
), - 将标量值转换为数组(使用
FILTER_FORCE_ARRAY
标志), - 在失败的情况下将值设置为
default
值、false
或null
(注意,即使提供了默认值,也不是所有标准过滤器都使用default
值), - 如果值是数组(或通过
FILTER_FORCE_ARRAY
标志变为数组),则这些修改将递归地应用于所有数组元素;
- 更改值(例如:
- 取消设置值将使其设置为
default
值(如果已定义)或所有过滤器中的null
。
更多用例
在特定的控制器中,我们处理以下请求参数
{ "id": {"type": "integer"}, "name": {"type": "string"}, "email": {"type": "string"}, "tel": {"type": "string"} }
因此我们定义以下过滤器
$filters = [ 'id' => [ 'filter' => FILTER_VALIDATE_INT, 'flags' => FILTER_REQUIRE_SCALAR, ], 'name' => [ 'filter' => FILTER_SANITIZE_STRING, 'flags' => FILTER_REQUIRE_SCALAR, ], 'email' => [ 'filter' => FILTER_VALIDATE_EMAIL, 'flags' => FILTER_REQUIRE_SCALAR, ], 'tel' => [ 'filter' => FILTER_VALIDATE_REGEXP, 'flags' => FILTER_REQUIRE_SCALAR, 'options' => [ 'regexp' => '/^\+?\d+(?:[. ()-]{1,2}\d+)*$/', 'default' => '+00 (0)0 00 00 00 00', ], ], ];
然后我们创建一个 ValidArray
实例,传递预定义的 $filters
和 $_REQUEST
的值(这通常累积 GET、POST 和 COOKIE 参数的值)
$va = new ValidArray($filters, $_REQUEST);
现在我们可以确信 $va
数组的计数正好为 4(id
、name
、email
、tel
键),每个键对应的类型或 null
(如果请求中没有提供或传入的值不符合过滤器定义)。如果未提供或无效,将使用 tel
元素的默认值。
由于 ValidArray
继承了 ArrayObject
,其元素可以通过正常的数组表示法访问(添加、迭代等)
$va['name'] = 'John Snow'; echo "{$va['name']}\n"; // prints: John Snow foreach ($va as $name => $value) { printf("'%s' => %s,\n", $name, var_export($value, true)); } // prints (if $_REQUEST is empty): // 'id' => null, // 'name' => 'John Snow', // 'email' => null, // 'tel' => '+00 (0)0 00 00 00 00',
设置 ValidArray
值时,它们将自动使用预定义的过滤器进行验证。这里再次,如果提供的值未通过验证且未定义默认值,则设置 false
(或如果设置了 FILTER_NULL_ON_FAILURE
标志,则设置为 null
)。
$va['email'] = 'unknown'; echo "{$va['email']}\n"; // prints empty line since $va['email'] is false
更多示例
对于以下请求参数
{ "id": 175, "name": "John Snow", "email": "john.snow@winterfell.com", "tel": "322-223" }
$va
内容将是
[ 'id' => 175, 'name' => 'John Snow', 'email' => 'john.snow@winterfell.com', 'tel' => '322-223', ]
对于错误的请求参数
{ "id": [1, "' OR 1 -- "], "name": "X", "another": true, "admin": 1 }
$va
内容将是
[ 'id' => false, 'name' => 'X', 'email' => null, 'tel' => "+00 (0)0 00 00 00 00", ]
在这里,将出现正确的参数,所有未知参数将被忽略,缺失的参数将设置为 null
或提供的 default
值,错误的参数将设置为 false
。
附加过滤器
这些附加过滤器提供以增强标准 php-filter
功能
ValidArray::FILTER_EXTENDED_CALLBACK
ValidArray::FILTER_INSTANCE_OF
重要
ValidArray
对象中的过滤器是只读的,在其对象生命周期内不能更改。这是设计如此。如果您需要一个可更新过滤器的对象,请使用提供的MutableValidArray
对象。