PHP 中的 OAuth 2.0 授权服务器实现:使用 League OAuth2 Server 的配置与扩展
大家好,今天我们来深入探讨如何在 PHP 中实现 OAuth 2.0 授权服务器,并重点讲解如何使用 League OAuth2 Server 这个强大的库进行配置和扩展。OAuth 2.0 协议是目前最流行的授权协议,它允许第三方应用安全地访问用户的资源,而无需暴露用户的凭据。League OAuth2 Server 库提供了一套完整的工具,可以帮助我们快速构建符合 OAuth 2.0 规范的授权服务器。
OAuth 2.0 协议基础
在深入代码之前,我们先回顾一下 OAuth 2.0 的核心概念和流程。OAuth 2.0 的主要参与者包括:
- Resource Owner (资源所有者): 拥有资源的用户。
- Client (客户端): 想要访问资源的应用。
- Authorization Server (授权服务器): 验证资源所有者的身份,并授权客户端访问资源。
- Resource Server (资源服务器): 托管受保护的资源,并验证客户端的访问令牌。
OAuth 2.0 的基本流程如下:
- 客户端向授权服务器发起授权请求。
- 授权服务器验证资源所有者的身份,并询问是否授权客户端访问资源。
- 如果资源所有者授权,授权服务器会颁发授权码 (Authorization Code) 或访问令牌 (Access Token) 给客户端。
- 客户端使用授权码向授权服务器请求访问令牌和刷新令牌 (Refresh Token)。
- 客户端使用访问令牌向资源服务器请求受保护的资源。
- 资源服务器验证访问令牌,并返回受保护的资源。
OAuth 2.0 定义了多种授权模式 (Grant Types),包括:
- Authorization Code Grant: 用于 Web 应用,安全性高,需要授权码交换。
- Implicit Grant: 用于单页应用 (SPA),安全性较低,直接返回访问令牌。
- Resource Owner Password Credentials Grant: 用于受信任的应用,直接使用用户名和密码获取访问令牌,安全性较低。
- Client Credentials Grant: 用于客户端访问自身资源,无需用户授权。
- Refresh Token Grant: 用于更新访问令牌,无需用户再次授权。
League OAuth2 Server 安装与基本配置
首先,我们需要使用 Composer 安装 League OAuth2 Server 库:
composer require league/oauth2-server
安装完成后,我们需要创建一个授权服务器实例,并配置相关的组件。以下是一个简单的配置示例:
<?php
use LeagueOAuth2ServerAuthorizationServer;
use LeagueOAuth2ServerGrantAuthCodeGrant;
use LeagueOAuth2ServerRepositoriesClientRepositoryInterface;
use LeagueOAuth2ServerRepositoriesAccessTokenRepositoryInterface;
use LeagueOAuth2ServerRepositoriesRefreshTokenRepositoryInterface;
use LeagueOAuth2ServerRepositoriesScopeRepositoryInterface;
use PsrHttpMessageResponseInterface;
use PsrHttpMessageServerRequestInterface;
use NyholmPsr7FactoryPsr17Factory;
use DoctrineDBALDriverManager;
use LeagueOAuth2ServerCryptKey;
// 数据库配置
$dbParams = [
'dbname' => 'oauth2',
'user' => 'root',
'password' => 'password',
'host' => 'localhost',
'driver' => 'pdo_mysql',
];
$conn = DriverManager::getConnection($dbParams);
// 创建 PSR-17 工厂
$psr17Factory = new Psr17Factory();
// 创建授权服务器实例
$server = new AuthorizationServer(
new ClientRepository($conn),
new AccessTokenRepository($conn),
new RefreshTokenRepository($conn),
new ScopeRepository($conn),
'file://' . __DIR__ . '/private.key', // 私钥路径
'file://' . __DIR__ . '/public.key' // 公钥路径
);
// 启用授权码授权模式
$grant = new AuthCodeGrant(
new AuthorizationCodeRepository($conn),
new RefreshTokenRepository($conn),
new DateInterval('PT10M') // 授权码有效期
);
$grant->setRefreshTokenTTL(new DateInterval('P1M')); // 刷新令牌有效期
$server->enableGrantType(
$grant,
new DateInterval('PT1H') // 访问令牌有效期
);
// 创建请求和响应实例 (示例)
$request = ($psr17Factory->createServerRequest('GET', '/authorize'));
$response = $psr17Factory->createResponse();
// 处理授权请求 (示例)
try {
$response = $server->validateAuthorizationRequest($request);
// 在这里,您需要验证用户身份,并询问用户是否授权客户端访问资源
// 如果用户授权,则重定向到客户端的回调地址,并附带授权码
// 例如:
// $response = $psr17Factory->createResponse(302)
// ->withHeader('Location', 'https://client.example.com/callback?code=AUTHORIZATION_CODE');
// 或者,如果用户拒绝授权,则重定向到客户端的回调地址,并附带错误信息
// 例如:
// $response = $psr17Factory->createResponse(302)
// ->withHeader('Location', 'https://client.example.com/callback?error=access_denied');
} catch (LeagueOAuth2ServerExceptionOAuthServerException $exception) {
// 处理授权服务器异常
$response = $exception->generateHttpResponse($response);
} catch (Exception $exception) {
// 处理其他异常
$response = (new LaminasDiactorosResponseFactory())->createResponse(500);
$response->getBody()->write($exception->getMessage());
}
//发送响应
(new LaminasHttpHandlerRunnerEmitterSapiEmitter())->emit($response);
代码解释:
- 引入必要的类: 首先,我们引入 League OAuth2 Server 库中需要的类,以及 PSR-7 相关的类。
- 数据库配置: 配置数据库连接信息,使用 Doctrine DBAL 连接数据库。
- 创建 PSR-17 工厂: 创建 PSR-17 工厂,用于创建请求和响应实例。 League OAuth2 Server 遵循 PSR-7 规范。
- 创建授权服务器实例: 创建
AuthorizationServer实例,需要传入以下参数:ClientRepositoryInterface: 用于管理客户端信息的仓库。AccessTokenRepositoryInterface: 用于管理访问令牌的仓库。RefreshTokenRepositoryInterface: 用于管理刷新令牌的仓库。ScopeRepositoryInterface: 用于管理权限范围的仓库。privateKey: 私钥路径,用于生成 JWT 访问令牌。publicKey: 公钥路径,用于验证 JWT 访问令牌。
- 启用授权码授权模式: 创建
AuthCodeGrant实例,并设置授权码和刷新令牌的有效期。然后,使用enableGrantType方法启用授权码授权模式,并设置访问令牌的有效期。 - 处理授权请求: 使用
validateAuthorizationRequest方法验证授权请求。 在实际应用中,你需要根据验证结果进行用户身份验证和授权操作。 - 处理异常: 捕获
OAuthServerException和其他异常,并生成相应的 HTTP 响应。 - 发送响应: 使用
LaminasHttpHandlerRunnerEmitterSapiEmitter发送响应。
重要提示:
-
你需要生成私钥和公钥,并将其路径配置到
AuthorizationServer实例中。 可以使用openssl命令生成密钥对:openssl genrsa -out private.key 2048 openssl rsa -in private.key -pubout -out public.key -
你需要实现
ClientRepositoryInterface,AccessTokenRepositoryInterface,RefreshTokenRepositoryInterface,ScopeRepositoryInterface和AuthorizationCodeRepository接口,并将其注入到AuthorizationServer和AuthCodeGrant实例中。 这些接口负责从数据库或其他存储介质中读取和存储客户端、令牌和权限范围信息。
数据库表结构
为了存储客户端、令牌和权限范围信息,我们需要创建相应的数据库表。以下是一些示例表结构:
clients 表:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | VARCHAR(255) | 客户端 ID (主键) |
| name | VARCHAR(255) | 客户端名称 |
| redirect_uri | TEXT | 回调地址 |
| secret | VARCHAR(255) | 客户端密钥 (可选) |
| grant_types | VARCHAR(255) | 支持的授权模式 (JSON 数组) |
access_tokens 表:
| 字段名 | 类型 | 说明 |
|---|---|---|
| identifier | VARCHAR(255) | 访问令牌 ID (主键) |
| user_id | VARCHAR(255) | 用户 ID |
| client_id | VARCHAR(255) | 客户端 ID |
| scopes | TEXT | 权限范围 (JSON 数组) |
| expiry_date_time | DATETIME | 过期时间 |
| revoked | BOOLEAN | 是否已撤销 |
refresh_tokens 表:
| 字段名 | 类型 | 说明 |
|---|---|---|
| identifier | VARCHAR(255) | 刷新令牌 ID (主键) |
| access_token_id | VARCHAR(255) | 访问令牌 ID |
| expiry_date_time | DATETIME | 过期时间 |
| revoked | BOOLEAN | 是否已撤销 |
scopes 表:
| 字段名 | 类型 | 说明 |
|---|---|---|
| identifier | VARCHAR(255) | 权限范围 ID (主键) |
| description | TEXT | 权限范围描述 |
auth_codes 表:
| 字段名 | 类型 | 说明 |
|---|---|---|
| identifier | VARCHAR(255) | 授权码 ID (主键) |
| user_id | VARCHAR(255) | 用户 ID |
| client_id | VARCHAR(255) | 客户端 ID |
| scopes | TEXT | 权限范围 (JSON 数组) |
| expiry_date_time | DATETIME | 过期时间 |
| revoked | BOOLEAN | 是否已撤销 |
实现 Repository 接口
接下来,我们需要实现 ClientRepositoryInterface, AccessTokenRepositoryInterface, RefreshTokenRepositoryInterface, ScopeRepositoryInterface 和 AuthorizationCodeRepository 接口,并将其注入到 AuthorizationServer 和 AuthCodeGrant 实例中。 以下是一个 ClientRepository 的示例实现:
<?php
use LeagueOAuth2ServerEntitiesClientEntityInterface;
use LeagueOAuth2ServerRepositoriesClientRepositoryInterface;
use DoctrineDBALConnection;
class ClientRepository implements ClientRepositoryInterface
{
private $conn;
public function __construct(Connection $conn)
{
$this->conn = $conn;
}
/**
* {@inheritdoc}
*/
public function getClientEntity($clientIdentifier): ?ClientEntityInterface
{
$stmt = $this->conn->executeQuery('SELECT * FROM clients WHERE id = ?', [$clientIdentifier]);
$clientData = $stmt->fetchAssociative();
if ($clientData === false) {
return null;
}
$client = new ClientEntity();
$client->setIdentifier($clientData['id']);
$client->setName($clientData['name']);
$client->setRedirectUri(json_decode($clientData['redirect_uri'])); // redirect_uri 存储为 JSON 数组
if (isset($clientData['secret'])) {
$client->setSecret($clientData['secret']);
}
return $client;
}
/**
* {@inheritdoc}
*/
public function validateClient(
$clientIdentifier,
$clientSecret,
$grantType
): bool {
$client = $this->getClientEntity($clientIdentifier);
if ($client === null) {
return false;
}
if ($client->getSecret() !== null && password_verify($clientSecret, $client->getSecret()) === false) {
return false;
}
//check the grant type
$grantTypes = json_decode($client->getGrantTypes() ?? '[]', true);
if (!in_array($grantType, $grantTypes)) {
return false;
}
return true;
}
}
代码解释:
getClientEntity方法: 根据客户端 ID 从数据库中读取客户端信息,并返回一个ClientEntity实例。validateClient方法: 验证客户端 ID、客户端密钥和授权模式是否有效。 如果客户端密钥存在,则使用password_verify函数验证密钥。
其他 Repository 接口的实现方式类似,需要根据数据库表结构和业务逻辑进行相应的调整。
扩展 League OAuth2 Server
League OAuth2 Server 提供了很多扩展点,可以帮助我们定制授权服务器的功能。以下是一些常见的扩展场景:
- 自定义授权模式 (Grant Type): 如果 OAuth 2.0 标准授权模式无法满足需求,可以自定义授权模式。
- 自定义 Scope 验证: 可以自定义 Scope 验证逻辑,例如根据用户角色或其他条件限制 Scope 的访问权限。
- 自定义 Token 生成: 可以自定义 Token 的生成方式,例如添加自定义 Claim 到 JWT 中。
- 自定义 Token 存储: 可以使用不同的存储介质存储 Token,例如 Redis 或 Memcached。
自定义授权模式 (Grant Type) 示例:
假设我们需要创建一个自定义的 "Device Code Grant" 授权模式,用于设备无需用户交互的情况下获取访问令牌。
-
创建 Grant 类:
<?php use LeagueOAuth2ServerGrantAbstractGrant; use LeagueOAuth2ServerEntitiesClientEntityInterface; use LeagueOAuth2ServerEntitiesUserEntityInterface; use LeagueOAuth2ServerRepositoriesRefreshTokenRepositoryInterface; use LeagueOAuth2ServerRequestEvent; use PsrHttpMessageServerRequestInterface; use LeagueOAuth2ServerResponseTypesResponseTypeInterface; class DeviceCodeGrant extends AbstractGrant { /** * @param RefreshTokenRepositoryInterface $refreshTokenRepository */ public function __construct( RefreshTokenRepositoryInterface $refreshTokenRepository ) { $this->setRefreshTokenRepository($refreshTokenRepository); $this->refreshTokenTTL = new DateInterval('P1M'); } /** * {@inheritdoc} */ public function getIdentifier() { return 'device_code'; } /** * {@inheritdoc} */ public function respondToAccessTokenRequest( ServerRequestInterface $request, ResponseTypeInterface $responseType, DateInterval $accessTokenTTL ) { $client = $this->validateClient($request); $scopes = $this->validateScopes($this->getRequestParameter('scope', $request)); $user = $this->validateUser($request, $client); // Implement this method to validate the device code // Finalize the requested scopes $finalizedScopes = $this->scopeRepository->finalizeScopes( $scopes, $this->getIdentifier(), $client, $user->getIdentifier() ); // Issue and persist new tokens $accessToken = $this->issueAccessToken($accessTokenTTL, $client, $user->getIdentifier(), $finalizedScopes); $refreshToken = $this->issueRefreshToken($accessToken); // Inject tokens into the response $responseType->setAccessToken($accessToken); $responseType->setRefreshToken($refreshToken); return $responseType; } /** * @param ServerRequestInterface $request * @param ClientEntityInterface $clientEntity * @return UserEntityInterface * @throws OAuthServerException */ protected function validateUser(ServerRequestInterface $request, ClientEntityInterface $clientEntity): UserEntityInterface { $deviceCode = $this->getRequestParameter('device_code', $request); if ($deviceCode === null) { $this->emitter->emit(new RequestEvent(RequestEvent::DEVICE_CODE_MISSING, $request)); throw OAuthServerException::invalidRequest('device_code'); } // Validate the device code against your data store. This is a placeholder. $user = $this->getUserEntityByDeviceCode($deviceCode, $clientEntity, $this->getIdentifier()); if ($user instanceof UserEntityInterface === false) { throw OAuthServerException::invalidGrant(); } return $user; } private function getUserEntityByDeviceCode(string $deviceCode, ClientEntityInterface $client, string $grantType): ?UserEntityInterface { //Look up your user based on the device code and client //Consider also validating the grant type //Return null or a UserEntityInterface instance //This is just a placeholder. You will need to implement this. return null; } } -
注册 Grant:
$grant = new DeviceCodeGrant( new RefreshTokenRepository($conn) ); $grant->setRefreshTokenTTL(new DateInterval('P1M')); $server->enableGrantType( $grant, new DateInterval('PT1H') // access token validity );
代码解释:
DeviceCodeGrant类: 继承AbstractGrant类,并实现getIdentifier和respondToAccessTokenRequest方法。getIdentifier方法: 返回授权模式的唯一标识符。respondToAccessTokenRequest方法: 处理访问令牌请求,验证客户端、Scope 和用户身份,并颁发访问令牌和刷新令牌。 你需要根据实际业务逻辑实现validateUser方法,验证设备码的有效性。getUserEntityByDeviceCode方法: 根据设备码和客户端信息查找用户。 这只是一个占位符,你需要根据实际情况实现此方法。- 注册 Grant: 创建
DeviceCodeGrant实例,并使用enableGrantType方法注册到授权服务器。
保护 API 资源
授权服务器颁发访问令牌后,资源服务器需要验证访问令牌的有效性,才能允许客户端访问受保护的资源。 League OAuth2 Server 提供了一个 ResourceServer 类,可以帮助我们验证访问令牌。
<?php
use LeagueOAuth2ServerResourceServer;
use LeagueOAuth2ServerRepositoriesAccessTokenRepositoryInterface;
use PsrHttpMessageServerRequestInterface;
use PsrHttpMessageResponseInterface;
use NyholmPsr7FactoryPsr17Factory;
// 创建 PSR-17 工厂
$psr17Factory = new Psr17Factory();
// 创建资源服务器实例
$server = new ResourceServer(
new AccessTokenRepository($conn),
'file://' . __DIR__ . '/public.key' // 公钥路径
);
// 创建请求实例 (示例)
$request = ($psr17Factory->createServerRequest('GET', '/resource'))
->withHeader('Authorization', 'Bearer ACCESS_TOKEN');
// 创建响应实例 (示例)
$response = $psr17Factory->createResponse();
try {
$request = $server->validateAuthenticatedRequest($request);
// 在这里,您可以访问受保护的资源
// 例如:
// $userId = $request->getAttribute('oauth_user_id');
// $clientId = $request->getAttribute('oauth_client_id');
// $scopes = $request->getAttribute('oauth_scopes');
$response->getBody()->write('Hello, authenticated user!');
} catch (LeagueOAuth2ServerExceptionOAuthServerException $exception) {
$response = $exception->generateHttpResponse($response);
} catch (Exception $exception) {
$response = (new LaminasDiactorosResponseFactory())->createResponse(500);
$response->getBody()->write($exception->getMessage());
}
(new LaminasHttpHandlerRunnerEmitterSapiEmitter())->emit($response);
代码解释:
- 创建资源服务器实例: 创建
ResourceServer实例,需要传入AccessTokenRepositoryInterface和公钥路径。 - 验证访问令牌: 使用
validateAuthenticatedRequest方法验证访问令牌。 如果访问令牌有效,则会将用户 ID、客户端 ID 和 Scope 信息添加到请求的属性中。 - 访问受保护的资源: 在
validateAuthenticatedRequest方法成功后,您可以访问受保护的资源。 可以使用$request->getAttribute方法获取用户 ID、客户端 ID 和 Scope 信息。 - 处理异常: 捕获
OAuthServerException和其他异常,并生成相应的 HTTP 响应。
安全建议
- 使用 HTTPS: OAuth 2.0 协议的所有交互都应该使用 HTTPS,以防止中间人攻击。
- 保护私钥: 私钥应该存储在安全的地方,并防止泄露。
- 验证回调地址: 授权服务器应该验证客户端的回调地址,以防止攻击者篡改授权码。
- 使用强密码: 客户端密钥应该使用强密码,并进行哈希处理。
- 限制 Scope 的访问权限: 应该根据用户角色或其他条件限制 Scope 的访问权限。
- 定期更新密钥: 应该定期更新私钥和客户端密钥。
关键点概括
我们学习了如何使用 League OAuth2 Server 库在 PHP 中实现 OAuth 2.0 授权服务器,包括安装配置,数据库结构,Repository 接口的实现,以及如何扩展 League OAuth2 Server 来满足自定义需求。同时,我们还探讨了如何使用 ResourceServer 类来保护 API 资源,并提供了一些安全建议。 掌握这些知识点,可以帮助你构建安全可靠的 OAuth 2.0 授权服务器。