曾经有一段时间,通过应用程序验证自己的唯一方法是提供您的凭据(通常是用户名或电子邮件地址和密码),然后使用会话来维护用户状态,直到用户注销。不久之后,我们开始使用身份验证 API。最近,JWT 或 JSON Web Tokens 越来越多地被用作对服务器请求进行身份验证的另一种方式。

在本文中,您将了解 JWT 是什么以及如何将它们与 PHP 一起使用来发出经过身份验证的用户请求。

JWT 与会话

但首先,为什么会话不是一件好事?嗯,有三个关键原因:

数据以纯文本形式存储在服务器上。
即使数据通常不存储在公用文件夹中,任何对服务器具有足够访问权限的人都可以读取会话文件的内容。它们涉及文件系统读/写请求。
每次会话开始或其数据被修改时,服务器都需要更新会话文件。每次应用程序发送会话 cookie 时也是如此。如果您有大量用户,则可能会导致服务器运行缓慢,除非您使用其他会话存储选项,例如 Memcached 和 Redis。分布式/集群应用程序。
由于会话文件默认存储在文件系统上,因此很难为高可用性应用程序(需要使用负载平衡器和集群服务器等技术的应用程序)提供分布式或集群基础架构。必须实施其他存储介质和特殊配置——并且在充分意识到它们的影响的情况下这样做。智威汤逊

现在,让我们开始学习 JWT。JSON Web Token 规范 (RFC 7519)于 2010 年 12 月 28 日首次发布,最近一次更新于 2015 年 5 月。

与 API 密钥相比,JWT 具有许多优势,包括:

API 密钥是随机字符串,而 JWT 包含信息和元数据。这些信息和元数据可以描述范围广泛的事物,例如用户的身份、授权数据以及令牌在时间范围内或与域相关的有效性。JWT 不需要集中的发行或撤销机构。JWT 与 OAUTH2 兼容。可以检查 JWT 数据。JWT 有过期控制。JWT 适用于空间受限的环境,例如 HTTP 授权标头。数据以 JavaScript Object Notation 格式 (JSON) 传输。JWT 使用Base64url 编码表示JWT 长什么样?

这是一个示例 JWT:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE0MTY5MjkxMDksImp0aSI6ImFhN2Y4ZDBhOTVjIiwic2NvcGVzIjpbInJlcG8iLCJwdWJsaWNfcmVwbyJdfQ.XCEwpBGvOLma4TCoh36FU7XhUbcskygS81HE1uHLf0E

乍一看,字符串似乎只是与句点或点字符连接的随机字符组。因此,它可能看起来与 API 密钥没有太大区别。但是,如果您仔细观察,会发现有三个单独的字符串。

JWT 标头

第一个字符串是 JWT 标头。它是一个Base64、URL 编码的 JSON 字符串。它指定了用于生成签名的加密算法,以及令牌的类型,该类型始终设置为JWT。该算法可以是对称的或非对称的。

对称算法使用单个密钥来创建和验证令牌。密钥在 JWT 的创建者和它的消费者之间共享。您必须确保只有创建者和消费者知道秘密。否则,任何人都可以创建有效的令牌。

非对称算法使用私钥对令牌进行签名,并使用公钥对其进行验证。当共享秘密不切实际或其他方只需要验证令牌的完整性时,应使用这些算法。

JWT 的有效负载

第二个字符串是 JWT 的有效负载。它也是一个 Base64、URL 编码的 JSON 字符串。它包含一些标准字段,称为“声明”。有三种类型的声明:注册的、公共的和私有的。

注册的索赔是预定义的。您可以在JWT 的 RFC中找到它们的列表。以下是一些常用的:

iat:令牌发行的时间戳。key:一个唯一的字符串,可用于验证令牌,但与没有集中的发行者权限相违背。iss: 包含发行者名称或标识符的字符串。可以是域名,可用于丢弃来自其他应用程序的令牌。nbf:令牌应该开始被认为有效的时间戳。应该等于或大于iat。exp:令牌应停止有效的时间戳。应该大于iat和nbf。

可以按照您认为合适的方式定义公共声明。但是,它们不能与已注册的声明或已存在的公共声明的声明相同。您可以随意创建私人索赔。它们仅用于两方之间:生产者和消费者。

JWT 的签名

JWT 的签名是一种加密机制,旨在使用令牌内容独有的数字签名来保护 JWT 的数据。签名确保 JWT 的完整性,以便消费者可以验证它没有被恶意行为者篡改。

JWT 的签名是三件事的组合:

JWT 的标头JWT 的有效载荷一个秘密值

这三个使用 JWT 标头中指定的算法进行数字签名(未加密)。如果我们对上面的示例进行解码,我们将得到以下 JSON 字符串:

JWT 的标头

{ "alg": "HS256", "typ": "JWT"}

JWT 的数据

{ "iat": 1416929109, "jti": "aa7f8d0a95c", "scopes": [ "repo", "public_repo" ]}

亲自试用jwt.io,您可以在其中对自己的 JWT 进行编码和解码。

让我们在基于 PHP 的应用程序中使用 JWT

现在您已经了解了 JWT 是什么,现在是学习如何在 PHP 应用程序中使用它们的时候了。在我们深入研究之前,请随意克隆本文的代码,或者在我们进行的过程中跟随并创建它。

有很多方法可以实现集成 JWT,但我们将采用以下方法。

除登录和注销页面外,对应用程序的所有请求都需要通过 JWT 进行身份验证。如果用户在没有 JWT 的情况下发出请求,他们将被重定向到登录页面。

用户填写并提交登录表单后,表单将通过 JavaScript 提交到authenticate.php我们应用程序中的登录端点 。然后端点将从请求中提取凭据(用户名和密码)并检查它们是否有效。

如果是,它将生成一个 JWT 并将其发送回客户端。当客户端收到 JWT 时,它将存储它并在以后对应用程序的每个请求中使用它。

对于一个简单的场景,用户只能请求一个资源——一个恰当命名为resource.php. 它不会做太多,只是返回一个字符串,其中包含请求时的当前时间戳。

发出请求时有几种使用 JWT 的方法。在我们的应用程序中,JWT 将在Bearer 授权标头中发送。

如果您不熟悉承载授权,它是一种 HTTP 身份验证形式,其中令牌(例如 JWT)在请求标头中发送。服务器可以检查令牌并确定是否应将访问权限授予令牌的“持有者”。

这是标题的示例:

Authorization: Bearer ab0dde18155a43ee83edba4a4542b973

对于我们的应用程序收到的每个请求,PHP 将尝试从 Bearer 标头中提取令牌。如果存在,则对其进行验证。如果它有效,用户将看到该请求的正常响应。但是,如果 JWT 无效,则不允许用户访问该资源。

请注意,JWT并非旨在替代会话 cookie。

先决条件

首先,我们需要在我们的系统上安装PHP和Composer 。

在项目的根目录中,运行composer install. 这将引入 Firebase PHP-JWT,这是一个第三方库,可简化 JWT 的使用,以及 laminas -config,旨在简化对应用程序中配置数据的访问

登录表格

安装完库后,让我们单步执行authenticate.php. 我们首先进行常规设置,确保 Composer 生成的自动加载器可用。

<?phpdeclare(strict_types=1);use Firebase\JWT\JWT;require_once('../vendor/autoload.php');

收到表单提交后,凭据将根据数据库或其他一些数据存储进行验证。出于本示例的目的,我们将假设它们是有效的,并设置$hasValidCredentials为 true。

<?php// extract credentials from the requestif ($hasValidCredentials) {

接下来,我们初始化一组用于生成 JWT 的变量。请记住,由于可以在客户端检查 JWT,因此不要在其中包含任何敏感信息。

值得再次指出的另一件事是,它$secretKey不会像这样初始化。您可能会在环境中设置它并使用诸如phpdotenv 之类的库或在配置文件中提取它。在此示例中,我避免了这样做,因为我想专注于 JWT 代码。

永远不要泄露它或将它存储在版本控制之下!

$secretKey = 'bGS6lzFqvvSQ8ALbOxatm7/Vk7mLQyzqaS34Q4oR1ew=';$issuedAt = new DateTimeImmutable();$expire = $issuedAt->modify('+6 minutes')->getTimestamp(); // Add 60 seconds$serverName = "your.domain.name";$username = "username"; // Retrieved from filtered POST data$data = [ 'iat' => $issuedAt->getTimestamp(), // Issued at: time when the token was generated 'iss' => $serverName, // Issuer 'nbf' => $issuedAt->getTimestamp(), // Not before 'exp' => $expire, // Expire 'userName' => $username, // User name];

准备好有效负载数据后,我们接下来使用 php-jwt 的静态encode方法创建 JWT。

方法:

将数组转换为 JSON产生标题签署有效载荷编码最终的字符串

它需要三个参数:

有效载荷信息密钥用于签署令牌的算法

通过调用echo函数的结果,返回生成的令牌:

<?php // Encode the array to a JWT string. echo JWT::encode( $data, $secretKey, 'HS512' );}使用 JWT

现在客户端有了令牌,您可以使用 JavaScript 或您喜欢的任何机制来存储它。这是一个如何使用 vanilla JavaScript 执行此操作的示例。中index.html,表单提交成功后,返回的JWT存储在内存中,登录表单隐藏,显示请求时间戳的按钮:

const store = {};const loginButton = document.querySelector('#frmLogin');const btnGetResource = document.querySelector('#btnGetResource');const form = document.forms[0];// Inserts the jwt to the store objectstore.setJWT = function (data) { this.JWT = data;};loginButton.addEventListener('submit', async (e) => { e.preventDefault(); const res = await fetch('/authenticate.php', { method: 'POST', headers: { 'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body: JSON.stringify({ username: form.inputEmail.value, password: form.inputPassword.value }) }); if (res.status >= 200 && res.status <= 299) { const jwt = await res.text(); store.setJWT(jwt); frmLogin.style.display = 'none'; btnGetResource.style.display = 'block'; } else { // Handle errors console.log(res.status, res.statusText); }});使用智威汤逊

单击“获取当前时间戳”按钮时,会向 发出 GET 请求,该请求会resource.php在 Authorization 标头中设置身份验证后收到的 JWT。

btnGetResource.addEventListener('click', async (e) => { const res = await fetch('/resource.php', { headers: { 'Authorization': `Bearer ${store.JWT}` } }); const timeStamp = await res.text(); console.log(timeStamp);});

当我们单击该按钮时,会发出类似于以下的请求:

GET /resource.php HTTP/1.1Host: yourhost.comConnection: keep-aliveAccept: */*X-Requested-With: XMLHttpRequestAuthorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE0MjU1ODg4MjEsImp0aSI6IjU0ZjhjMjU1NWQyMjMiLCJpc3MiOiJzcC1qd3Qtc2ltcGxlLXRlY25vbTFrMy5jOS5pbyIsIm5iZiI6MTQyNTU4ODgyMSwiZXhwIjoxNDI1NTkyNDIxLCJkYXRhIjp7InVzZXJJZCI6IjEiLCJ1c2VyTmFtZSI6ImFkbWluIn19.HVYBe9xvPD8qt0wh7rXI8bmRJsQavJ8Qs29yfVbY-A0

假设 JWT 有效,我们会看到资源,然后将响应写入控制台。

验证 JWT

最后,让我们看看如何在 PHP 中验证令牌。和往常一样,我们会包含 Composer 的自动加载器。然后,我们可以选择检查是否使用了正确的请求方法。我跳过了执行此操作的代码,继续关注 JWT 特定的代码:

<?phpchdir(dirname(__DIR__));require_once('../vendor/autoload.php');// Do some checking for the request method here, if desired.

然后,代码将尝试从 Bearer 标头中提取令牌。我已经使用preg_match做到了。如果您不熟悉该函数,它会对字符串执行正则表达式匹配

我在这里使用的正则表达式将尝试从 Bearer 标头中提取令牌,并转储其他所有内容。如果未找到,则返回 HTTP 400 错误请求:

if (! preg_match('/Bearer\s(\S+)/', $_SERVER['HTTP_AUTHORIZATION'], $matches)) { header('HTTP/1.0 400 Bad Request'); echo 'Token not found in request'; exit;}

请注意,默认情况下,Apache不会将标HTTP_AUTHORIZATION头传递给 PHP。这背后的原因是:

基本授权标头仅在您的连接通过 HTTPS 完成时才安全,否则凭据将通过网络以编码的纯文本(未加密)发送,这是一个巨大的安全问题。

我完全理解这个决定的逻辑。但是,为了避免很多混淆,请将以下内容添加到您的 Apache 配置中。然后代码将按预期运行。如果您使用的是 NGINX,则代码应按预期运行:

RewriteEngine OnRewriteCond %{HTTP:Authorization} ^(.+)$RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]

接下来,我们尝试提取匹配的 JWT,它将位于$matches变量的第二个元素中。如果它不可用,则没有提取 JWT,并返回 HTTP 400 错误请求:

$jwt = $matches[1];if (! $jwt) { // No token was able to be extracted from the authorization header header('HTTP/1.0 400 Bad Request'); exit;}

如果我们到了这一点,提取了 JWT,因此我们进入解码和验证阶段。为此,我们再次需要我们的密钥,该密钥将从环境或应用程序的配置中提取。然后我们使用 php-jwt 的静态decode方法,将 JWT、密钥和用于解码 JWT 的算法数组传递给它。

如果它能够被成功解码,我们就会尝试验证它。我在这里的例子非常简单,因为它只使用发行者,而不是之前和到期时间戳。在实际应用程序中,您可能还会使用许多其他声明。

$secretKey = 'bGS6lzFqvvSQ8ALbOxatm7/Vk7mLQyzqaS34Q4oR1ew=';$token = JWT::decode($jwt, $secretKey, ['HS512']);$now = new DateTimeImmutable();$serverName = "your.domain.name";if ($token->iss !== $serverName || $token->nbf > $now->getTimestamp() || $token->exp < $now->getTimestamp()){ header('HTTP/1.1 401 Unauthorized'); exit;}

如果令牌无效,例如令牌已过期,则会向用户发送 HTTP 401 Unauthorized 标头,并且脚本将退出。

如果解码 JWT 的过程失败,可能是:

提供的段数与前面描述的标准三不匹配。标头或有效负载不是有效的 JSON 字符串签名无效,说明数据被篡改!nbf当当前时间戳小于该时间戳时,该声明在 JWT 中设置为时间戳。iat当当前时间戳小于该时间戳时,该声明在 JWT 中设置为时间戳。当exp当前时间戳大于该时间戳时,声明会在 JWT 中设置一个时间戳。

如您所见,JWT 有一组很好的控件,可以将其标记为无效,而无需手动撤销它或根据有效令牌列表检查它。

如果解码和验证过程成功,用户将被允许发出请求,并将被发送适当的响应。

综上所述

这是对 JSON Web 令牌或 JWT 以及如何在基于 PHP 的应用程序中使用它们的快速介绍。从这里开始,您可以尝试在您的下一个 API 中实现 JWT,也许可以尝试使用其他一些使用非对称密钥(如 RS256)的签名算法,或者将其集成到现有的 OAUTH2 身份验证服务器中作为 API 密钥。