在 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 中使用它进行身份认证,因为二者的特点十分契合。
安装及配置
需求
- PHP 7.2+
- Composer
- OpenSSL
- Slim 4 应用,可参考 Slim 4 搭建 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_key
和 public_key
对应的值即 private.pem
和 public.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 库可供选择吗?
- 如果不想以公/私钥的形式使用 JWT,可以尝试 tuupola/slim-jwt-auth
- 如果使用 BasicAuth 认证方式,可以尝试 tuupola/slim-basic-auth
Comments NOTHING