跳到主要内容

使用基于角色的访问控制 (RBAC) 和 JWT 验证保护你的 Slim API

本指南将帮助你通过实现授权 (Authorization),结合 基于角色的访问控制 (RBAC) 和由 Logto 签发的 JSON Web Token (JWT),来保护你的 Slim API。

开始之前

你的客户端应用需要从 Logto 获取访问令牌 (Access tokens)。如果你还没有完成客户端集成,请查看我们的 快速开始,适用于 React、Vue、Angular 或其他客户端框架,或者参考我们的 机器对机器指南 以实现服务器到服务器的访问。

本指南聚焦于在你的 Slim 应用中对这些令牌进行服务端验证

A figure showing the focus of this guide

你将学到什么

  • JWT 验证:学习如何验证访问令牌 (Access tokens) 并提取认证 (Authentication) 信息
  • 中间件实现:创建可复用的中间件以保护 API
  • 权限模型:理解并实现不同的授权 (Authorization) 模式:
    • 应用级端点的全局 API 资源
    • 用于租户特定功能控制的组织权限
    • 多租户数据访问的组织级 API 资源
  • RBAC 集成:在你的 API 端点中强制执行基于角色的权限 (Permissions) 和权限范围 (Scopes)

前置条件

  • 已安装 PHP 的最新稳定版本
  • 基本了解 Slim 及 Web API 开发
  • 已配置 Logto 应用(如有需要请参见 快速开始

权限 (Permission) 模型概览

在实施保护之前,请选择适合你应用架构的权限 (Permission) 模型。这与 Logto 的三大授权 (Authorization) 场景保持一致:

全局 API 资源 RBAC
  • 使用场景: 保护整个应用共享的 API 资源(非组织 (Organization) 专属)
  • 令牌类型: 具有全局受众 (Audience) 的访问令牌 (Access token)
  • 示例: 公共 API、核心产品服务、管理端点
  • 最适合: 所有客户都使用 API 的 SaaS 产品、无租户隔离的微服务
  • 了解更多: 保护全局 API 资源

💡 在继续之前选择你的模型 —— 本指南后续内容将以你选择的方式为参考。

快速准备步骤

配置 Logto 资源和权限

  1. 创建 API 资源:前往 控制台 → API 资源 并注册你的 API(例如,https://api.yourapp.com
  2. 定义权限:添加如 read:productswrite:orders 等权限(Scopes)——参见 定义带权限的 API 资源
  3. 创建全局角色:前往 控制台 → 角色 并创建包含你的 API 权限的角色——参见 配置全局角色
  4. 分配角色:将角色分配给需要访问 API 的用户或 M2M 应用程序
初次接触基于角色的访问控制 (RBAC)?:

从我们的 基于角色的访问控制 (RBAC) 指南 开始,获取分步设置说明。

更新你的客户端应用

在客户端请求合适的权限(Scopes):

通常需要在客户端配置中更新以下一项或多项:

  • OAuth 流程中的 scope 参数
  • 用于 API 资源访问的 resource 参数
  • 用于组织上下文的 organization_id
编码前须知:

请确保你测试的用户或 M2M 应用已被分配包含所需 API 权限的合适角色或组织角色。

初始化你的 API 项目

要初始化一个新的 Slim 项目,你可以使用 Composer 创建项目结构:

mkdir your-api-name
cd your-api-name
composer init

安装 Slim Framework 及所需依赖:

composer require slim/slim:"4.*"
composer require slim/psr7
composer require slim/http

创建基础项目结构:

mkdir -p public src/Middleware src/Controllers

创建一个基础的 Slim 应用程序:

public/index.php
<?php

use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;

require __DIR__ . '/../vendor/autoload.php';

$app = AppFactory::create();

// 添加错误中间件
$app->addErrorMiddleware(true, true, true);

// 基础路由
$app->get('/', function (Request $request, Response $response) {
$response->getBody()->write(json_encode(['message' => 'Hello from Slim API']));
return $response->withHeader('Content-Type', 'application/json');
});

$app->run();

如果你使用了 mkdir 方式,请创建一个基础的 composer.json 文件:

composer.json
{
"name": "your-name/your-api-name",
"description": "A Slim Framework API",
"type": "project",
"require": {
"php": "^8.1",
"slim/slim": "4.*",
"slim/psr7": "^1.6",
"slim/http": "^1.3"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"config": {
"process-timeout": 0,
"sort-packages": true
}
}

启动开发服务器:

php -S localhost:8000 -t public/
备注:

更多关于如何设置路由、中间件及其他功能的详细信息,请参考 Slim Framework 官方文档。

初始化常量和工具方法

在你的代码中定义必要的常量和工具函数,用于处理令牌的提取和校验。一个有效的请求必须包含 Authorization 请求头,格式为 Bearer <访问令牌 (Access token)>

AuthConstants.php
<?php

class AuthConstants
{
public const JWKS_URI = 'https://your-tenant.logto.app/oidc/jwks';
public const ISSUER = 'https://your-tenant.logto.app/oidc';
}
AuthInfo.php
<?php

class AuthInfo
{
public function __construct(
public readonly string $sub,
public readonly ?string $clientId = null,
public readonly ?string $organizationId = null,
public readonly array $scopes = [],
public readonly array $audience = []
) {}

public function toArray(): array
{
return [
'sub' => $this->sub,
'client_id' => $this->clientId,
'organization_id' => $this->organizationId,
'scopes' => $this->scopes,
'audience' => $this->audience,
];
}
}
AuthorizationException.php
<?php

class AuthorizationException extends Exception
{
public function __construct(
string $message,
public readonly int $statusCode = 403
) {
parent::__construct($message);
}
}
AuthHelpers.php
<?php

trait AuthHelpers
{
protected function extractBearerToken(array $headers): string
{
$authorization = $headers['authorization'][0] ?? $headers['Authorization'][0] ?? null;

if (!$authorization) {
throw new AuthorizationException('Authorization header is missing', 401);
}

if (!str_starts_with($authorization, 'Bearer ')) {
throw new AuthorizationException('Authorization header must start with "Bearer "', 401);
}

return substr($authorization, 7); // 移除 'Bearer ' 前缀
}
}

获取你的 Logto 租户信息

你需要以下数值来验证 Logto 签发的令牌:

  • JSON Web Key Set (JWKS) URI:Logto 公钥的 URL,用于验证 JWT 签名。
  • 发行者 (Issuer):期望的发行者 (Issuer) 值(Logto 的 OIDC URL)。

首先,找到你的 Logto 租户的端点。你可以在多个地方找到它:

  • 在 Logto 控制台的 设置域名 下。
  • 在你在 Logto 配置的任何应用程序设置中,设置端点与凭证

从 OpenID Connect 发现端点获取

这些数值可以从 Logto 的 OpenID Connect 发现端点获取:

https://<your-logto-endpoint>/oidc/.well-known/openid-configuration

以下是一个示例响应(为简洁省略了其他字段):

{
"jwks_uri": "https://your-tenant.logto.app/oidc/jwks",
"issuer": "https://your-tenant.logto.app/oidc"
}

由于 Logto 不允许自定义 JWKS URI 或发行者 (Issuer),你可以在代码中硬编码这些数值。但对于生产环境的应用程序,这并不推荐,因为如果将来某些配置发生变化,可能会增加维护成本。

  • JWKS URI: https://<your-logto-endpoint>/oidc/jwks
  • 发行者 (Issuer): https://<your-logto-endpoint>/oidc

校验令牌和权限

在提取令牌并获取 OIDC 配置后,请验证以下内容:

  • 签名: JWT 必须有效且由 Logto(通过 JWKS)签名。
  • 发行者 (Issuer): 必须与你的 Logto 租户的发行者 (Issuer) 匹配。
  • 受众 (Audience): 必须与你在 Logto 中注册的 API 的资源指示器 (resource indicator) 匹配,或在适用时匹配组织 (organization) 上下文。
  • 过期时间: 令牌必须未过期。
  • 权限 (Scopes): 令牌必须包含你的 API / 操作所需的权限 (scopes)。权限 (scopes) 是 scope 声明中的以空格分隔的字符串。
  • 组织 (Organization) 上下文: 如果保护的是组织级 API 资源,请验证 organization_id 声明。

参见 JSON Web Token 以了解更多关于 JWT 结构和声明 (Claims) 的信息。

针对每种权限 (Permission) 模型需要检查什么

不同的权限 (Permission) 模型,其声明 (Claims) 和验证规则也不同:

  • 受众 (Audience) 声明 (aud): API 资源指示器 (resource indicator)
  • 组织 (Organization) 声明 (organization_id): 不存在
  • 需要检查的权限 (Scopes) (scope): API 资源权限 (permissions)

对于非 API 的组织 (Organization) 权限 (Permissions),组织上下文由 aud 声明表示 (例如,urn:logto:organization:abc123)。organization_id 声明仅在组织级 API 资源令牌中存在。

提示:

对于安全的多租户 API,请始终同时验证权限 (scopes) 和上下文(受众 (audience)、组织 (organization))。

添加校验逻辑

我们使用 firebase/php-jwt 来验证 JWT。使用 Composer 安装它:

composer require firebase/php-jwt

首先,添加这些通用工具来处理 JWT 验证:

JwtValidator.php
<?php

use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
use Firebase\JWT\Key;

class JwtValidator
{
use AuthHelpers;

private static ?array $jwks = null;

public static function fetchJwks(): array
{
if (self::$jwks === null) {
$jwksData = file_get_contents(AuthConstants::JWKS_URI);
if ($jwksData === false) {
throw new AuthorizationException('Failed to fetch JWKS', 401);
}

self::$jwks = json_decode($jwksData, true);
}

return self::$jwks;
}

public static function validateJwt(string $token): array
{
try {
$jwks = self::fetchJwks();
$keys = JWK::parseKeySet($jwks);

$decoded = JWT::decode($token, $keys);
$payload = (array) $decoded;

// 验证发行者 (Issuer)
if (($payload['iss'] ?? '') !== AuthConstants::ISSUER) {
throw new AuthorizationException('Invalid issuer', 401);
}

self::verifyPayload($payload);
return $payload;

} catch (AuthorizationException $e) {
throw $e;
} catch (Exception $e) {
throw new AuthorizationException('Invalid token: ' . $e->getMessage(), 401);
}
}

public static function createAuthInfo(array $payload): AuthInfo
{
$scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];
$audience = $payload['aud'] ?? [];

if (is_string($audience)) {
$audience = [$audience];
}

return new AuthInfo(
sub: $payload['sub'],
clientId: $payload['client_id'] ?? null,
organizationId: $payload['organization_id'] ?? null,
scopes: $scopes,
audience: $audience
);
}

private static function verifyPayload(array $payload): void
{
// 在此根据权限 (Permission) 模型实现你的验证逻辑
// 具体内容将在下方的权限 (Permission) 模型部分展示
}
}

然后,实现中间件以验证访问令牌 (Access token):

src/Middleware/JwtMiddleware.php
<?php

namespace App\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Response;

class JwtMiddleware implements MiddlewareInterface
{
use AuthHelpers;

public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
try {
$headers = $request->getHeaders();
$token = $this->extractBearerToken($headers);
$payload = JwtValidator::validateJwt($token);

// 将认证 (Authentication) 信息存储在请求属性中以便通用使用
$request = $request->withAttribute('auth', JwtValidator::createAuthInfo($payload));

return $handler->handle($request);

} catch (AuthorizationException $e) {
$response = new Response();
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));
return $response
->withHeader('Content-Type', 'application/json')
->withStatus($e->statusCode);
}
}
}

根据你的权限 (Permission) 模型,在 JwtValidator 中实现相应的验证逻辑:

JwtValidator.php
private static function verifyPayload(array $payload): void
{
// 检查受众 (Audience) 声明是否匹配你的 API 资源指示器 (Resource indicator)
$audiences = $payload['aud'] ?? [];
if (is_string($audiences)) {
$audiences = [$audiences];
}

if (!in_array('https://your-api-resource-indicator', $audiences)) {
throw new AuthorizationException('Invalid audience');
}

// 检查全局 API 资源所需的权限 (Scopes)
$requiredScopes = ['api:read', 'api:write']; // 替换为你实际需要的权限 (Scopes)
$scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];

foreach ($requiredScopes as $scope) {
if (!in_array($scope, $scopes)) {
throw new AuthorizationException('Insufficient scope');
}
}
}

将中间件应用到你的 API

现在,将中间件应用到你的受保护 API 路由。

src/Controllers/ProtectedController.php
<?php

namespace App\Controllers;

use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;

class ProtectedController
{
public function index(Request $request, Response $response): Response
{
// 从请求属性中获取认证 (Authentication) 信息
$auth = $request->getAttribute('auth');
$response->getBody()->write(json_encode(['auth' => $auth->toArray()]));
return $response->withHeader('Content-Type', 'application/json');
}

public function detailed(Request $request, Response $response): Response
{
// 你的受保护端点逻辑
$auth = $request->getAttribute('auth');
$data = [
'auth' => $auth->toArray(),
'message' => '受保护数据访问成功'
];
$response->getBody()->write(json_encode($data));
return $response->withHeader('Content-Type', 'application/json');
}
}

测试你的受保护 API

获取访问令牌 (Access tokens)

从你的客户端应用程序获取: 如果你已经完成了客户端集成,你的应用可以自动获取令牌。提取访问令牌 (Access token),并在 API 请求中使用它。

使用 curl / Postman 进行测试:

  1. 用户令牌: 使用你的客户端应用的开发者工具,从 localStorage 或网络面板复制访问令牌 (Access token)

  2. 机器对机器令牌: 使用客户端凭证流。以下是一个使用 curl 的非规范示例:

    curl -X POST https://your-tenant.logto.app/oidc/token \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "grant_type=client_credentials" \
    -d "client_id=your-m2m-client-id" \
    -d "client_secret=your-m2m-client-secret" \
    -d "resource=https://your-api-resource-indicator" \
    -d "scope=api:read api:write"

    你可能需要根据你的 API 资源和权限调整 resourcescope 参数;如果你的 API 是组织范围的,还可能需要 organization_id 参数。

提示:

需要查看令牌内容?使用我们的 JWT 解码器 来解码和验证你的 JWT。

测试受保护的端点

有效令牌请求
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
http://localhost:3000/api/protected

预期响应:

{
"auth": {
"sub": "user123",
"clientId": "app456",
"organizationId": "org789",
"scopes": ["api:read", "api:write"],
"audience": ["https://your-api-resource-indicator"]
}
}
缺少令牌
curl http://localhost:3000/api/protected

预期响应 (401):

{
"error": "Authorization header is missing"
}
无效令牌
curl -H "Authorization: Bearer invalid-token" \
http://localhost:3000/api/protected

预期响应 (401):

{
"error": "Invalid token"
}

权限模型相关测试

针对受全局权限保护的 API 的测试场景:

  • 有效权限 (Scopes): 使用包含所需 API 权限(如 api:readapi:write)的令牌进行测试
  • 缺少权限 (Scopes): 当令牌缺少所需权限时,预期返回 403 Forbidden
  • 受众 (Audience) 错误: 当受众与 API 资源不匹配时,预期返回 403 Forbidden
# 缺少权限的令牌 - 预期 403
curl -H "Authorization: Bearer token-without-required-scopes" \
http://localhost:3000/api/protected

延伸阅读

RBAC 实践:为你的应用实现安全授权 (Authorization)

构建多租户 SaaS 应用:从设计到实现的完整指南