Slim 4 搭建 RESTful API 中,我们搭建了一个简单的 RESTful API 应用,接下来我们需要解决一个对网站的正常使用和安全至关重要的问题:身份验证。好在我们有一个和 RESTful API 十分搭配的身份验证方式 —— JWT(JSON Web Token)。

本文的安装和基本使用教程是在 Daniel Opitz 的 Slim 4 - OAuth 2.0 and JSON Web Token (JWT) Setup 基础上进行了些许修改和补充完成的。关于 Slim 4 的更多进阶用法可以访问他的博客 Daniel's Dev Blog

为什么是 JWT?

传统的用户认证一般使用会话Session),但是在使用 Session 时会面临以下几个问题:

  • Session 保存在服务端,随着用户数量的增长,服务器的开销会明显增大。
  • 同样由于保存在服务端,Session 在面临跨域问题时显得有点力不从心。

这个时候,JWT 的核心特点——无状态,就体现出了它的优势:由于服务器不再保存 Session 数据,所有数据都保存在客户端,且随着每次请求发回服务器,那么首先服务器的开销就会由固定的存储资源转向动态的计算资源;而基于无状态的特点,应用也就不再需要去考虑用户在哪一台服务器登录了,这也是 JWT 在跨域问题上天然拥有的优势。

不过 JWT 也存在着不少局限性,例如 Token 的续签、中止等等,这也意味着 JWT 不适用于用户注销这类不符合无状态原则的功能。

但是这些并不影响我们在 RESTful API 中使用它进行身份认证,因为二者的特点十分契合。

安装及配置

需求

安装

安装 RFC 7519 的实现 lcobucci/jwt,这是一个很棒的 JWT 库,使用 JWS(JSON Web Signature)来实现 JWT:

composer require lcobucci/jwt

安装 ramsey/uuid,一个 UUID 生成器库:

composer require ramsey/uuid

安装 cakephp/chronos,一个日期时间库:

composer require cakephp/chronos

生成密钥对

在本文中,我们使用 RSA 密钥来签名并验证 JWT。首先需要生成 RSA 私钥:

openssl genrsa -out private.pem 2048

生成的私钥将保存在 private.pem 文件中。

注意:上述命令中的 “2048” 为密钥位数。你可以任意选择以下位数之一:512、758、1024、2048 或 4096,较大的位数可以提供更高的安全性,也会占用更多的 CPU 资源,所以根据需要选择你的密钥位数。

接下来生成 RSA 公钥:

openssl rsa -in private.pem -outform PEM -pubout -out public.pem

生成的公钥将保存在 public.pem 文件中。

配置

将以下设置信息添加到 Slim 的配置文件中,例如 config/settings.php

$settings["jwt"] = [
    "issuer" => "api.ncepuers.com",
    "lifetime" => 604800
    "private_key" =>
"-----BEGIN RSA PRIVATE KEY-----
MIIBPQIBAAJBALSlDUfngNVzILQh0UDzg22Wd3NCvHrl1PMK+IxRoTQovLN3TQ8E
oBgL7GqHTYSrnnADrV0JSgf8onbDzkvZoYcCAwEAAQJBALEY5w5JPZsFRViTlsww
b/bt/qk3EgUCcWTcqpMWLA4vBBH7/guLZvyWG1U4Q63vgO1SSA7g+bwMvmDMCj6l
fhECIQDc6/A9mYXirCwHL0iKb5o1R/ri4NqZYrcoGUrYhpntZQIhANFT7k4SffT0
3PSK1Pa2OcsnfuBMmqZcld3DP8+lCip7AiEAhRaZ9vIaxxBDwdxJTiSneLuxN6aP
6mGex0hdX43PA0UCIQDNZjD41LZZjYfeQPg1WZueF5QsnZ5GTaUUpIjRxF0UTwIh
AMa/1Gkl/FUaiZaFm6KMysKHAeWg3YZudouHoDLDcDbl
-----END RSA PRIVATE KEY-----";
    "public_key" =>
"-----BEGIN PUBLIC KEY-----
MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALSlDUfngNVzILQh0UDzg22Wd3NCvHrl
1PMK+IxRoTQovLN3TQ8EoBgL7GqHTYSrnnADrV0JSgf8onbDzkvZoYcCAwEAAQ==
-----END PUBLIC KEY-----";
];

其中 private_keypublic_key 对应的值即 private.pempublic.pem 文件的内容。

注意:不要将私钥提交到 Git 中,这会使 Token 可以被伪造。

部署

创建 JWT

新建文件 src/Auth/JwtAuth.php 并写入以下代码:

<?php

namespace App\Auth;

use Cake\Chronos\Chronos;
use InvalidArgumentException;
use Lcobucci\JWT\Builder;
use Lcobucci\JWT\Parser;
use Lcobucci\JWT\Signer\Key;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Token;
use Lcobucci\JWT\ValidationData;
use Ramsey\Uuid\Uuid;
use UnexpectedValueException;

final class JwtAuth
{
    /** @var string The issuer name */
    private string $issuer;

    /** @var int Max lifetime in seconds */
    private int $lifetime;

    /** @var string The private key */
    private string $privateKey;

    /** @var string The public key */
    private string $publicKey;

    /** @var Sha256 The signer */
    private Sha256 $signer;

    /**
     * The constructor.
     *
     * @param string $issuer The issuer name
     * @param int $lifetime The max lifetime
     * @param string $privateKey The private key as string
     * @param string $publicKey The public key as string
     */
    public function __construct(
        string $issuer,
        int $lifetime,
        string $privateKey,
        string $publicKey
    ) {
        $this->issuer = $issuer;
        $this->lifetime = $lifetime;
        $this->privateKey = $privateKey;
        $this->publicKey = $publicKey;
        $this->signer = new Sha256();
    }

    /**
     * Get JWT max lifetime.
     *
     * @return int The lifetime in seconds
     */
    public function getLifetime(): int
    {
        return $this->lifetime;
    }

    /**
     * Create JSON web token.
     *
     * @param string $uid The user id
     *
     * @throws UnexpectedValueException
     *
     * @return string The JWT
     */
    public function createJwt(string $uid): string
    {
        $issuedAt = Chronos::now()->getTimestamp();

        // (JWT ID) Claim, a unique identifier for the JWT
        return (new Builder())->issuedBy($this->issuer)
            ->identifiedBy(Uuid::uuid4()->toString(), true)
            ->issuedAt($issuedAt)
            ->canOnlyBeUsedAfter($issuedAt)
            ->expiresAt($issuedAt + $this->lifetime)
            ->withClaim("uid", $uid)
            ->getToken($this->signer, new Key($this->privateKey));
    }

    /**
     * Parse token.
     *
     * @param string $token The JWT
     *
     * @throws InvalidArgumentException
     *
     * @return Token The parsed token
     */
    public function createParsedToken(string $token): Token
    {
        return (new Parser())->parse($token);
    }

    /**
     * Validate the access token.
     *
     * @param string $accessToken The JWT
     *
     * @return bool The status
     */
    public function validateToken(string $accessToken): bool
    {
        $token = $this->createParsedToken($accessToken);

        if (!$token->verify($this->signer, $this->publicKey)) {
            // Token signature is not valid
            return false;
        }

        // Check whether the token has not expired
        $data = new ValidationData();
        $data->setCurrentTime(Chronos::now()->getTimestamp());
        $data->setIssuer($token->getClaim("iss"));
        $data->setId($token->getClaim("jti"));

        return $token->validate($data);
    }
}

JwtAuth 类可以完成包括生成、解密、验证 Token 在内的常用功能。

添加容器定义

config/container.php 中添加以下定义:

<?php

use App\Auth\JwtAuth;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Selective\Config\Configuration;

return [
    ResponseFactoryInterface::class => function (ContainerInterface $container) {
        return $container->get(App::class)->getResponseFactory();
    },

    JwtAuth::class => function (ContainerInterface $container) {
        $config = $container->get(Configuration::class);

        $issuer = $config->getString("jwt.issuer");
        $lifetime = $config->getInt("jwt.lifetime");
        $privateKey = $config->getString("jwt.private_key");
        $publicKey = $config->getString("jwt.public_key");

        return new JwtAuth($issuer, $lifetime, $privateKey, $publicKey);
    },
];

创建 Token

接下来就可以添加一条路由来创建 Token 了。新建一个动作类 src/Action/TokenCreateAction.php 并写入以下代码:

<?php

namespace App\Action;

use App\Auth\JwtAuth;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

final class TokenCreateAction
{
    /** @var JwtAuth JWT authorizer */
    private JwtAuth $jwtAuth;

    public function __construct(JwtAuth $jwtAuth)
    {
        $this->jwtAuth = $jwtAuth;
    }

    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $data = (array)$request->getParsedBody();

        $username = (string)($data["username"] ?? "");
        $password = (string)($data["password"] ?? "");

        // Validate login (pseudo code)
        // Warning: This should be done in an application service and not here!
        // e.g. $isValidLogin = $this->userAuth->checkLogin($username, $password); 
        $isValidLogin = ($username === "user" && $password === "secret");

        if (!$isValidLogin) {
            // Invalid authentication credentials
            return $response
                ->withHeader("Content-Type", "application/json")
                ->withStatus(401, "Unauthorized");
        }

        // Create a fresh token
        $token = $this->jwtAuth->createJwt($username);
        $lifetime = $this->jwtAuth->getLifetime();

        // Transform the result into a OAuh 2.0 Access Token Response
        // https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/
        $result = [
            "access_token" => $token,
            "token_type" => "Bearer",
            "expires_in" => $lifetime,
        ];

        // Build the HTTP response
        $response = $response->withHeader("Content-Type", "application/json");
        $response->getBody()->write((string)json_encode($result));

        return $response->withStatus(201);
    }
}

然后在路由配置文件 config/routes.php 中添加一条新路由:

$app->post("/api/tokens", \App\Action\TokenCreateAction::class);

现在向我们注册的路由 /api/tokens 发送 POST 请求,Body 如下:

{
    "username": "user",
    "password": "secret"
}

就可以收到带有 JWT 的响应内容了。

注意:Slim 4 默认的 BodyParsingMiddleware 只在设置了正确的请求头时才会解析请求体,所以确认你所发送的 POST 请求包含以下请求头:

Content-Type: application/json

使用 Token

生成 JWT 后自然要用它来访问 API。我们通常以 Bearer 认证的方式来使用 JWT,Bearer 认证(也称为 Token 认证)是 HTTP 认证方案的一种,最初在 RFC 6750 中被定义为 OAuth 2.0 的一部分。

要使用 Bearer 认证就需要在 HTTP 请求中加上 Authorization 请求头,从而发送 JWT:

Authorization: Bearer <Token>

Bearer 认证中间件

在 Slim 中,我们可以通过创建并添加一个 Bearer 认证中间件的方式来解析并验证客户端发送的 JWT。

新建文件 src/Middleware/JwtMiddleware.php 并写入以下代码:

<?php

namespace App\Middleware;

use App\Auth\JwtAuth;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
 * JWT middleware.
 */
final class JwtMiddleware implements MiddlewareInterface
{
    /** @var JwtAuth JWT authorizer */
    private JwtAuth $jwtAuth;

    /**
 @var ResponseFactoryInterface
 The response factory */
    private ResponseFactoryInterface $responseFactory;

    public function __construct(JwtAuth $jwtAuth, ResponseFactoryInterface $responseFactory)
    {
        $this->jwtAuth = $jwtAuth;
        $this->responseFactory = $responseFactory;
    }

    /**
     * Invoke middleware.
     *
     * @param ServerRequestInterface $request The request
     * @param RequestHandlerInterface $handler The handler
     *
     * @return ResponseInterface The response
     */
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $authorization = explode(" ", (string)$request->getHeaderLine("Authorization"));
        $token = $authorization[1] ?? "";

        if (!$token || !$this->jwtAuth->validateToken($token)) {
            return $this->responseFactory->createResponse()
                ->withHeader("Content-Type", "application/json")
                ->withStatus(401, "Unauthorized");
        }

        // Append valid token
        $parsedToken = $this->jwtAuth->createParsedToken($token);
        $request = $request->withAttribute("token", $parsedToken);

        // Append the user id as request attribute
        $request = $request->withAttribute("uid", $parsedToken->getClaim("uid"));

        return $handler->handle($request);
    }
}

使用 JWT 保护路由

如果要保护单个路由,只需要将 JwtMiddleware 添加到要保护的路由中即可,例如:

$app->post("/users", \App\Action\UserCreateAction::class)
    ->add(\App\Middleware\JwtMiddleware::class);

如果要保护一个路由组,也只需要将 JwtMiddleware添加到要保护的路由组中即可,例如:

<?php

use App\Middleware\JwtMiddleware;
use Slim\App;
use Slim\Routing\RouteCollectorProxy;

return function (App $app) {
    // This route must not be protected
    $app->post("/api/tokens", \App\Action\TokenCreateAction::class);

    // Protect the whole group
    $app->group("/api", function (RouteCollectorProxy $group) {
        $group->get("/users/{id}", \App\Action\UserReadAction::class);
        $group->post("/users", \App\Action\UserCreateAction::class);
    })->add(JwtMiddleware::class);

};

Done!

JWT 的基本使用就到这里了。在实际使用中可能用不到这么多 Claim,也可能增加自定义的 Claim,根据需要修改 JwtAuth 和 JwtMiddleware 即可轻松实现想要的功能。

最后,还是要注意不要泄露私钥,以及尽量控制过期时间(本文中为了演示,设为了 604800,即 1 周)。

常见问题

如何处理 CORS 预检请求

使用 CORS 中间件,并对 URL 增加 OPTION 路由即可解决这个问题。

详情参阅这篇文章:Slim 4 - CORS setup

如何存储 Token

参阅这两篇文章:

POST 请求中没有 Authorization 请求头

一般情况下,Apache 都能正确处理 Authorization 请求头,如果确实遇到这样的情况,那么将以下内容添加到 .htaccess 文件中:

RewriteRule .* - [env=HTTP_AUTHORIZATION:%{HTTP:Authorization}]

详情参阅这篇文章:Authorization header missing in PHP POST request

有其他 JWT 库可供选择吗?