使用授权者保护 SQLite 数据库

许多 PHP 开发人员喜欢使用 SQLite 作为他们的数据库引擎,而不是单独的数据库服务器,如 PostgreSQLMySQLOracleMongoDB。使用 SQLite 的原因有很多,但通常归结为以下几点:

  • SQLite 是一种基于文件的数据库:你无需安装单独的数据库服务器。

  • 它易于分发:唯一的要求是目标服务器必须安装 SQLite 可执行文件。

  • SQLite 重量轻:由于没有持续运行的服务器,它占用的资源更少。

不过,它的缺点是可扩展性不强。如果要处理的数据量相当大,最好安装功能更强大的数据库服务器。另一个潜在的主要缺点是 SQLite 没有安全性,这将在下一小节中介绍。

有关 SQLite 的更多信息,请访问其主页: https://sqlite.org/index.html

等一下…​没有安全?

是的,你没有听错:默认情况下,SQLite 的设计本身就不具备安全性。当然,这也是许多开发人员喜欢使用它的原因:没有安全性使其超级易于使用!

下面是一段连接到 SQLite 数据库并对 geonames 表进行简单查询的示例代码。它将返回印度人口超过 200 万的城市列表:

// /repo/ch02/php8_sqlite_query.php
define('DB_FILE', __DIR__ . '/tmp/sqlite.db');
$sqlite = new SQLite3(DB_FILE);
$sql = 'SELECT * FROM geonames '
    . 'WHERE country_code = :cc AND population > :pop';
$stmt = $sqlite->prepare($sql);
$stmt->bindValue(':cc', 'IN');
$stmt->bindValue(':pop', 2000000);
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
    printf("%20s : %2s : %16s\n",
        $row['name'], $row['country_code'],
        number_format($row['population']));
} // not all code is shown

大多数其他数据库扩展在建立连接时至少需要用户名和密码。正如你在前面的代码片段中看到的,$sqlite 实例的创建完全不涉及安全问题:没有用户名或密码。

什么是 SQLite 授权回调?

SQLite3 引擎现在允许您为 SQLite 数据库连接注册 授权回调。回调例程会在发送 准备语句 到数据库进行编译时被调用。以下是在 SQLite3 实例上设置授权回调的通用语法:

$sqlite3->setAuthorizer(callable $callback);

预计回调将返回三个 SQLite3 类常量之一,每个常量代表一个整数值。如果回调返回的不是这三个值之一,则假定 SQLite3::DENY,操作不会继续。下表列出了三个预期返回值:

Table 1. Table 2.2 – Valid SQLite authorization callback return values
Constant Description

SQLite3::OK

成功

SQLite3::IGNORE

Statement is allowed to prepare,but is rendered neutral

SQLite3::DENY

拒绝访问

既然你已经对回调有了一定的了解,让我们来看看它是如何被调用的。

什么被发送到回调?

执行 $sqlite->prepare($sql) 时会调用回调。此时,SQLite3 引擎会向回调传递一到五个参数。第一个参数是一个 动作代码,它决定了其余参数的性质。因此,对于您最终定义的回调,下面可能是一个合适的通用函数签名:

function NAME (int $actionCode, ...$params)
{ /* callback code */ };

大部分操作代码反映了要准备的 SQL 语句。下表总结了一些较常见的操作代码:

image 2023 11 20 19 01 14 937
Figure 1. Table 2.3 – Common action codes sent to callback

现在我们来看一个使用示例。

授权者使用示例

在下面的示例中,我们可以读取 SQLite geonames 表,但不能插入、删除或更新:

  1. 我们首先在 /repo/ch02/includes/ 目录中定义一个 auth_callback.php include 文件。在 include 文件中,我们首先定义回调中使用的常量,如下代码片段所示:

    // /repo/ch02/includes/auth_callback.php
    define('DB_FILE', '/tmp/sqlite.db');
    define('PATTERN', '%-8s | %4s | %-28s | %-15s');
    define('DEFAULT_TABLE', 'Unknown');
    define('DEFAULT_USER', 'guest');
    define('ACL' , [
        'admin' => [
            'users' => [SQLite3::READ, SQLite3::SELECT,
                SQLite3::INSERT, SQLite3::UPDATE,
                SQLite3::DELETE],
            'geonames' => [SQLite3::READ, SQLite3::SELECT,
                SQLite3::INSERT, SQLite3::UPDATE,
                SQLite3::DELETE],
        ],
        'guest' => [
            'geonames' => [SQLite3::READ,
                SQLite3::SELECT],
        ],
    ]);

    访问控制列表(ACL)的工作方式是:主外键是用户(如 adminguest);次外键是表(如 usersgeonames);值是该用户和表所允许的 SQLite3 操作代码数组。

    在前面显示的示例中,admin 用户拥有两个表的所有权限,而 guest 用户只能读取 geonames 表。

  2. 接下来,我们定义实际的授权回调函数。在该函数中,我们需要做的第一件事是将默认返回值设置为 SQLite3::DENY。我们还要检查操作代码是否为 SQLite3::SELECT,在这种情况下,我们只需返回 OK。该操作代码在首次处理 SELECT 语句时发出,不提供任何有关表或列的信息。代码见以下代码段:

    function auth_callback(int $code, ...$args) {
        $status = SQLite3::DENY;
        $table = DEFAULT_TABLE;
        if ($code === SQLite3::SELECT) {
            $status = SQLite3::OK;
  3. 如果操作代码不是 SQLite3::SELECT,我们需要首先确定涉及的表,然后才能决定允许或拒绝该操作。表名将作为第二个参数报告给我们的回调。

  4. 这里是使用变量运算符的最佳时机,因为我们不确定究竟会传递多少个参数。不过,对于主要的相关操作(例如 INSERTUPDATEDELETE),放在 $args 第一个位置的就是表名。否则,我们将从会话中获取表名。

    下面的代码片段对代码进行了说明:

    } else {
        if (!empty($args[0])) {
            $table = $args[0];
        } elseif (!empty($_SESSION['table'])) {
            $table = $_SESSION['table'];
        }
  5. 同样,我们也可以从会话中获取用户名,如下所示:

    $user = $_SESSION['user'] ?? DEFAULT_USER;
  6. 接下来,我们会检查 ACL 中是否定义了该用户,然后检查是否为该用户分配了表的权限。如果给出的操作代码位于与用户和表组合相关联的数组中,则返回 SQLite3::OK

    代码显示在以下代码段中:

    if (!empty(ACL[$user])) {
        if (!empty(ACL[$user][$table])) {
            if (in_array($code, ACL[$user][$table])) {
                $status = SQLite3::OK;
            }
        }
    }
  7. 然后,我们将表名存储在会话中,并返回状态代码,如下代码所示:

        } // end of "if ($code === SQLite3::SELECT)"
        $_SESSION['table'] = $table;
        return $status;
    } // end of function definition

    现在我们将注意力转向调用程序。

  8. 在加入定义授权回调的 PHP 文件后,我们通过接受一个命令行参数、一个统一资源定位器(URL)参数或简单地指定 admin 来模拟获取用户名,如以下代码片段所示:

    // /repo/ch02/php8_sqlite_auth_admin.php
    include __DIR__ . '/includes/auth_callback.php';
    // Here we simulate the user acquisition:
    session_start();
    $_SESSION['user'] =
        $argv[1] ?? $_GET['usr'] ?? DEFAULT_USER;
  9. 接下来,我们创建两个数组,并使用 shuffle() 使它们的顺序随机。我们从随机数组中创建用户名、电子邮件和 ID 值,如下代码片段所示:

    $name = ['jclayton','mpaulovich','nrousseau',
        'jporter'];
    $email = ['unlikelysource.com',
        'lfphpcloud.net','phptraining.net'];
    shuffle($name);
    shuffle($email);
    $user_name = $name[0];
    $user_email = $name[0] . '@' . $email[0];
    $id = md5($user_email . rand(0,999999));
  10. 然后,我们创建 SQLite3 实例并分配授权回调,如下所示:

    $sqlite = new SQLite3(DB_FILE);
    $sqlite->setAuthorizer('auth_callback');
  11. SQL INSERT 语句现已定义,并发送至 SQLite 准备。请注意,此时将调用授权回调。

    代码如下所示:

    $sql = 'INSERT INTO users ' . 'VALUES (:id, :name, :email, :pwd);';
    $stmt = $sqlite->prepare($sql);
  12. 如果授权回调拒绝了该操作,则语句对象为空,因此最好使用 if() 语句来测试其是否存在。如果存在,我们就会继续绑定值并执行语句,如下面的代码片段所示:

    if ($stmt) {
        $stmt->bindValue(':id', $id);
        $stmt->bindValue(':name', $user_name);
        $stmt->bindValue(':email', $user_email);
        $stmt->bindValue(':pwd', 'password');
        $result = $stmt->execute();
  13. 为了确认结果,我们定义了一条 SQL SELECT 语句来显示 users 表的内容,如下所示:

    $sql = 'SELECT * FROM users';
    $result = $sqlite->query($sql);
    while ($row = $result->fetchArray(SQLITE3_ASSOC))
        printf("%-10s : %- 10s\n",
            $row['user_name'], $row['user_email']);
    }

此处显示的并非所有代码。完整代码请参阅 /repo/ch02/php8_sqlite_auth_admin.php。

下面是运行调用程序并将用户设置为 admin 后的结果:

image 2023 11 20 19 20 49 811
Figure 2. Figure 2.9 – SQLite3 authorization callback: admin user

前面截图的输出显示,由于我们是以 admin 用户身份运行,并拥有足够的授权权限,因此操作成功。下面是用户设置为 guest 时的输出结果:

image 2023 11 20 19 21 47 574
Figure 3. Figure 2.10 – SQLite3 authorization callback: guest user

输出结果显示,运行 prepare() 的尝试没有成功,因为我们是以缺乏足够授权的用户身份运行的。

我们对这一期待已久的功能的讨论到此结束。您现在知道了如何为不安全的数据库技术添加授权。

描述添加 SQLite 授权者的原始拉取请求: https://github.com/php/php-src/pull/4797

关于授权者回调的官方 SQLite 文档: https://www.sqlite.org/c3ref/set_authorizer.html

传递给回调的操作代码: https://www.sqlite.org/c3ref/c_alter_table.html

结果代码的完整列表: https://www.sqlite.org/rescode.html

SQLite3 类的文档: https://www.php.net/sqlite3

总结

在本章中,您了解了 PHP 8 在过程级引入的一些变化。首先了解了新的 nullsafe 操作符,它可以大大缩短对象引用链可能失败的代码。您还了解了三元运算符和变量运算符的使用是如何得到加强和改进的,以及连接运算符的优先级是如何降低的。本章还介绍了箭头函数的优缺点,以及如何使用箭头函数作为匿名函数的简洁替代。

本章后面的章节介绍了 PHP 8 如何延续 PHP 7 中首次引入的统一变量语法的趋势。您还将了解 PHP 8 如何解决剩余的不一致问题,包括内插字符串和魔法常数的去引用,以及数组和字符串处理方面的改进,这些改进有望使 PHP 8 更简洁、更高效。

最后,在上一节中,你将了解到一项新功能,它提供了对 SQLite 授权回调的支持,使你最终能在使用 SQLite 作为数据库时提供一定程度的安全性。

在下一章中,您将了解 PHP 8 的错误处理增强功能。