PHP GraphQL API 的身份验证:基于 Token 的 Header 与 Subscription 连接认证
大家好!今天我们要深入探讨 PHP GraphQL API 的身份验证,重点关注两种重要的场景:通过 HTTP Header 传递 Token 进行身份验证,以及在 GraphQL Subscription 建立连接时进行认证。这两种认证方式对于构建安全可靠的 GraphQL 应用至关重要。
一、GraphQL 身份验证的基础概念
在传统的 RESTful API 中,我们通常使用 Cookie、Session 或 Token 来进行身份验证。在 GraphQL API 中,Token 认证依然是主流的选择,但GraphQL的灵活性也带来了一些新的挑战,尤其是在 Subscription 场景下。
GraphQL 认证的核心目标是:
- 验证请求者的身份: 确保只有授权用户才能访问特定的数据和功能。
- 控制访问权限: 根据用户的角色和权限,限制其可以执行的操作。
- 处理不同类型的请求: 区分 Query、Mutation 和 Subscription,并采取相应的认证策略。
二、基于 Header 的 Token 认证 (Query/Mutation)
对于 GraphQL Query 和 Mutation,我们通常可以通过 HTTP Header 传递 Token 来进行身份验证。标准的做法是使用 Authorization Header,并遵循 Bearer <token> 的格式。
1. 实现步骤
- 客户端: 在发送 GraphQL 请求时,将 Token 添加到
AuthorizationHeader 中。 - 服务端: 在 GraphQL 解析器中,从 Header 中提取 Token,并进行验证。
2. 代码示例 (PHP)
首先,我们假设已经有一个生成和验证 JWT (JSON Web Token) 的工具类 JWTService。
<?php
// JWTService.php (简化版)
class JWTService {
private $secretKey;
public function __construct(string $secretKey) {
$this->secretKey = $secretKey;
}
public function generateToken(array $payload): string {
// 这里省略 JWT 生成的具体实现
// 使用 $payload 和 $this->secretKey 生成 JWT Token
// 例如使用 Firebase JWT 库或其他 JWT 库
// 假设生成的 token 为 $token
$header = base64_encode(json_encode(['typ' => 'JWT', 'alg' => 'HS256']));
$payloadEncoded = base64_encode(json_encode($payload));
$signature = hash_hmac('sha256', $header . '.' . $payloadEncoded, $this->secretKey, true);
$signatureEncoded = base64_encode($signature);
$token = $header . '.' . $payloadEncoded . '.' . $signatureEncoded;
return $token;
}
public function validateToken(string $token): ?array {
// 这里省略 JWT 验证的具体实现
// 使用 $token 和 $this->secretKey 验证 JWT Token
// 如果验证成功,返回 payload,否则返回 null
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
list($headerEncoded, $payloadEncoded, $signatureEncoded) = $parts;
$header = json_decode(base64_decode($headerEncoded), true);
$payload = json_decode(base64_decode($payloadEncoded), true);
$signature = base64_decode($signatureEncoded);
$expectedSignature = hash_hmac('sha256', $headerEncoded . '.' . $payloadEncoded, $this->secretKey, true);
if (hash_equals($signature, $expectedSignature)) {
return $payload;
} else {
return null;
}
}
}
// GraphQL Resolver (例如使用 webonyx/graphql-php)
use GraphQLTypeDefinitionResolveInfo;
class Query
{
private $jwtService;
public function __construct(JWTService $jwtService) {
$this->jwtService = $jwtService;
}
public function user( $rootValue, array $args, $context, ResolveInfo $info)
{
// 从 context 中获取用户身份信息
$user = $context['user'];
if (!$user) {
throw new Exception('Unauthorized'); // 或者返回 null,取决于你的业务逻辑
}
// 根据用户身份信息查询数据库,并返回用户数据
// ...
return [
'id' => $user['id'],
'name' => 'Authenticated User',
'email' => '[email protected]'
];
}
}
class Mutation {
private $jwtService;
public function __construct(JWTService $jwtService) {
$this->jwtService = $jwtService;
}
public function updateUser($rootValue, array $args, $context, ResolveInfo $info) {
$user = $context['user'];
if (!$user) {
throw new Exception('Unauthorized');
}
// 更新数据库中的用户数据
// ...
return [
'id' => $user['id'],
'name' => $args['name'],
'email' => $user['email']
];
}
}
// 关键代码:在GraphQL入口文件中,处理身份验证
// 例如 index.php
use GraphQLGraphQL;
use GraphQLTypeSchema;
use GraphQLTypeDefinitionObjectType;
use GraphQLTypeDefinitionType;
// 替换为你的实际 secret key
$secretKey = 'your-secret-key';
$jwtService = new JWTService($secretKey);
// 从 HTTP Header 中获取 Token
$authorizationHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$token = null;
if (preg_match('/Bearers+(.*)/', $authorizationHeader, $matches)) {
$token = $matches[1];
}
// 验证 Token
$user = null;
if ($token) {
$payload = $jwtService->validateToken($token);
if ($payload) {
// 将用户身份信息添加到 context 中
$user = $payload; // 例如:['id' => 123, 'username' => 'john.doe']
}
}
// 定义 GraphQL 类型
$userType = new ObjectType([
'name' => 'User',
'fields' => [
'id' => ['type' => Type::nonNull(Type::int())],
'name' => ['type' => Type::string()],
'email' => ['type' => Type::string()],
],
]);
$queryType = new ObjectType([
'name' => 'Query',
'fields' => [
'user' => [
'type' => $userType,
'resolve' => (new Query($jwtService))->user(...) // 使用构造函数注入
],
],
]);
$mutationType = new ObjectType([
'name' => 'Mutation',
'fields' => [
'updateUser' => [
'type' => $userType,
'args' => [
'name' => ['type' => Type::string()]
],
'resolve' => (new Mutation($jwtService))->updateUser(...)
]
]
]);
// 创建 GraphQL Schema
$schema = new Schema([
'query' => $queryType,
'mutation' => $mutationType,
]);
// 获取 GraphQL 查询字符串
$rawInput = file_get_contents('php://input');
$input = json_decode($rawInput, true);
$query = $input['query'];
$variables = $input['variables'] ?? null;
// 执行 GraphQL 查询
try {
$result = GraphQL::executeQuery($schema, $query, null, ['user' => $user], $variables); // 将 user 信息传递到 context
$output = $result->toArray();
} catch (Exception $e) {
$output = [
'errors' => [
['message' => $e->getMessage()]
]
];
}
// 输出结果
header('Content-Type: application/json');
echo json_encode($output);
3. 关键点
- Token 提取: 使用
$_SERVER['HTTP_AUTHORIZATION']获取AuthorizationHeader 的值,并解析出 Token。 - Token 验证: 使用
JWTService验证 Token 的有效性。 - Context 传递: 将验证后的用户身份信息 (例如用户 ID、角色等) 添加到 GraphQL 的
context中。context是一个在所有解析器之间共享的对象,我们可以通过它来传递身份验证信息。 - 权限控制: 在解析器中,根据
context中的用户身份信息,进行权限控制,决定是否允许用户访问特定的数据或执行特定的操作。 - 错误处理: 如果 Token 无效或用户没有权限,抛出异常或返回错误信息。
4. 客户端示例 (JavaScript)
// 发送 GraphQL 请求
fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token') // 从本地存储获取 Token
},
body: JSON.stringify({
query: `
query GetUser {
user {
id
name
email
}
}
`
})
})
.then(response => response.json())
.then(data => {
if (data.errors) {
console.error('GraphQL errors:', data.errors);
} else {
console.log('User data:', data.data.user);
}
});
三、Subscription 连接认证
GraphQL Subscription 使用 WebSocket 协议进行实时数据推送。因此,我们需要在 WebSocket 连接建立时进行身份验证。
1. 实现方式
常用的 Subscription 认证方式有:
connectionParams: 客户端在建立 WebSocket 连接时,通过connectionParams选项传递认证信息 (例如 Token)。- 自定义 WebSocket 子协议: 定义自定义的 WebSocket 子协议,用于在连接建立时进行认证。
我们这里重点讲解使用 connectionParams 的方式,因为它更简单且被广泛支持。
2. 代码示例 (PHP, Ratchet, webonyx/graphql-php)
首先,我们需要一个 WebSocket 服务器。 这里使用 Ratchet 作为例子:
<?php
// composer require cboden/ratchet react/event-loop
use RatchetMessageComponentInterface;
use RatchetConnectionInterface;
use RatchetServerIoServer;
use RatchetHttpHttpServer;
use RatchetWebSocketWsServer;
use GraphQLGraphQL;
use GraphQLTypeSchema;
use GraphQLTypeDefinitionObjectType;
use GraphQLTypeDefinitionType;
require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/JWTService.php'; // 引入 JWTService
class GraphQLSubscriptionServer implements MessageComponentInterface {
protected $clients;
private $jwtService;
private $schema;
public function __construct(JWTService $jwtService, Schema $schema) {
$this->clients = new SplObjectStorage;
$this->jwtService = $jwtService;
$this->schema = $schema;
}
public function onOpen(ConnectionInterface $conn) {
// Store the new connection to send messages to later
$this->clients->attach($conn);
echo "New connection! ({$conn->resourceId})n";
}
public function onMessage(ConnectionInterface $from, $msg) {
$numRecv = count($this->clients) - 1;
echo sprintf('Connection %d sending message "%s" to %d other connection%s' . "n"
, $from->resourceId, $msg, $numRecv, $numRecv == 1 ? '' : 's');
$data = json_decode($msg, true);
if (isset($data['type']) && $data['type'] === 'connection_init') {
// Extract token from connectionParams
$token = $data['payload']['token'] ?? null;
if ($token) {
$payload = $this->jwtService->validateToken($token);
if ($payload) {
// Store user information in the connection object
$from->user = $payload;
$from->send(json_encode(['type' => 'connection_ack'])); // Acknowledge the connection
echo "Connection {$from->resourceId} authenticated successfully.n";
return;
}
}
// Authentication failed
$from->send(json_encode(['type' => 'connection_error', 'payload' => ['message' => 'Authentication failed']]));
$from->close(); // Close the connection
return;
}
if (isset($data['type']) && $data['type'] === 'start') {
// Handle GraphQL subscription execution
$query = $data['payload']['query'];
$variables = $data['payload']['variables'] ?? null;
$operationName = $data['payload']['operationName'] ?? null;
if (!isset($from->user)) {
// Connection not authenticated
$from->send(json_encode(['type' => 'error', 'id' => $data['id'], 'payload' => ['message' => 'Unauthorized']]));
return;
}
try {
$result = GraphQL::executeQuery($this->schema, $query, null, ['user' => $from->user], $variables, $operationName);
$output = $result->toArray();
// Send GraphQL response back to client
$from->send(json_encode(['type' => 'data', 'id' => $data['id'], 'payload' => $output]));
$from->send(json_encode(['type' => 'complete', 'id' => $data['id']]));
} catch (Exception $e) {
$from->send(json_encode(['type' => 'error', 'id' => $data['id'], 'payload' => ['message' => $e->getMessage()]]));
}
}
foreach ($this->clients as $client) {
if ($from !== $client) {
// The sender is not the receiver, send to each client connected
//$client->send($msg); // 原始的消息转发
}
}
}
public function onClose(ConnectionInterface $conn) {
// The connection is closed, remove it, as we can no longer send it messages
$this->clients->detach($conn);
echo "Connection {$conn->resourceId} has disconnectedn";
}
public function onError(ConnectionInterface $conn, Exception $e) {
echo "An error has occurred: {$e->getMessage()}n";
$conn->close();
}
}
// GraphQL Schema definition (example)
$secretKey = 'your-secret-key';
$jwtService = new JWTService($secretKey);
$userType = new ObjectType([
'name' => 'User',
'fields' => [
'id' => ['type' => Type::nonNull(Type::int())],
'name' => ['type' => Type::string()],
'email' => ['type' => Type::string()],
],
]);
$queryType = new ObjectType([
'name' => 'Query',
'fields' => [
'user' => [
'type' => $userType,
'resolve' => function ($rootValue, $args, $context) {
// Access user information from context
$user = $context['user'];
if (!$user) {
throw new Exception('Unauthorized');
}
return [
'id' => $user['id'],
'name' => 'Authenticated User',
'email' => '[email protected]'
];
},
],
],
]);
$subscriptionType = new ObjectType([
'name' => 'Subscription',
'fields' => [
'userUpdates' => [
'type' => $userType,
'resolve' => function ($rootValue, $args, $context) {
// Access user information from context
$user = $context['user'];
if (!$user) {
throw new Exception('Unauthorized');
}
// Simulate user updates (replace with actual logic)
$userId = $user['id'];
$userName = 'Updated User ' . $userId;
$userEmail = '[email protected]';
// Yield the updated user data to the subscriber
yield [
'id' => $userId,
'name' => $userName,
'email' => $userEmail
];
},
'subscribe' => function ($rootValue, $args, $context) {
// Access user information from context
$user = $context['user'];
if (!$user) {
throw new Exception('Unauthorized');
}
// Simulate a stream of user updates (replace with actual logic)
$stream = new Generator(function () use ($user) {
for ($i = 0; $i < 5; $i++) {
// Simulate a delay between updates
sleep(1);
// Yield the user data for each update
yield $user;
}
});
return $stream;
}
],
],
]);
$schema = new Schema([
'query' => $queryType,
'subscription' => $subscriptionType,
]);
// Run the server application through the WebSocket protocol
$server = IoServer::factory(
new HttpServer(
new WsServer(
new GraphQLSubscriptionServer($jwtService, $schema)
)
),
8080
);
echo "WebSocket server started on port 8080n";
$server->run();
3. 客户端示例 (JavaScript, Apollo Client)
import { ApolloClient, InMemoryCache, HttpLink, split } from '@apollo/client';
import { WebSocketLink } from '@apollo/client/link/ws';
import { getMainDefinition } from '@apollo/client/utilities';
const httpLink = new HttpLink({
uri: 'http://localhost:8000/graphql', // 替换为你的 GraphQL HTTP 端点
});
// Create a WebSocket link:
const wsLink = new WebSocketLink({
uri: 'ws://localhost:8080', // 替换为你的 WebSocket 端点
options: {
reconnect: true,
connectionParams: {
token: localStorage.getItem('token'), // 从本地存储获取 Token
},
},
});
// using the ability to split links, you can send data to each link
// depending on what kind of operation is being sent
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
httpLink,
);
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache()
});
// 订阅示例
client.subscribe({
query: gql`
subscription UserUpdates {
userUpdates {
id
name
email
}
}
`
}).subscribe({
next(data) {
console.log('Subscription data:', data);
},
error(err) {
console.error('Subscription error:', err);
}
});
4. 关键点
connectionParams: 在WebSocketLink的options中,设置connectionParams,将 Token 传递给服务器。- 服务器端处理
connection_init消息: 服务器需要监听connection_init消息,从payload中提取 Token,并进行验证。 - 保存用户身份信息: 验证成功后,将用户身份信息保存在 WebSocket 连接对象中,以便在后续的 Subscription 请求中使用。
- 权限控制: 在 Subscription 的
resolve和subscribe函数中,根据用户身份信息进行权限控制。 - 发送
connection_ack或connection_error: 根据认证结果,向客户端发送connection_ack(认证成功) 或connection_error(认证失败) 消息。
5. Subscription 消息格式
GraphQL Over WebSocket 协议定义了一系列消息类型,用于控制 Subscription 的生命周期和数据传输。
| 消息类型 | 描述 | payload |
|---|---|---|
connection_init |
客户端发送,用于初始化连接,并传递认证信息。 | 认证信息 (例如 token) |
connection_ack |
服务器发送,表示连接已成功建立,并且认证已通过。 | 无 |
connection_error |
服务器发送,表示连接建立失败,或者认证失败。 | 错误信息 |
start |
客户端发送,用于启动一个 Subscription。 | GraphQL 查询字符串、变量等 |
data |
服务器发送,用于推送 Subscription 的数据。 | GraphQL 查询结果 |
error |
服务器发送,表示在执行 Subscription 过程中发生错误。 | 错误信息 |
complete |
服务器发送,表示 Subscription 已完成,不再有更多的数据推送。 | 无 |
stop |
客户端发送,用于停止一个 Subscription。 | Subscription 的 ID |
四、安全性考虑
- 使用 HTTPS: 务必使用 HTTPS 协议来保护 Token 在传输过程中的安全。
- Token 过期时间: 设置合理的 Token 过期时间,避免 Token 被长期滥用。
- 刷新 Token: 实现 Token 刷新机制,允许用户在 Token 过期前获取新的 Token,而无需重新登录。
- 存储敏感信息: 不要在客户端存储敏感信息 (例如密码)。 将 Token 存储在安全的地方,例如 HTTP-only Cookie 或 Keychain。
- 防止 CSRF 攻击: 对于 Mutation 请求,可以使用 CSRF Token 来防止跨站请求伪造攻击。
- 输入验证: 对所有输入数据进行验证,防止 SQL 注入、XSS 攻击等。
- 速率限制: 实施速率限制,防止 API 被滥用。
五、认证流程选择
| 认证场景 | 推荐方案 | 优点 | 缺点 |
|---|---|---|---|
| Query/Mutation | 基于 Header 的 Token 认证 (Bearer Token) | 简单易用,符合 RESTful API 的标准认证方式。 | 需要客户端在每次请求中都添加 Header。 |
| Subscription | connectionParams 传递 Token |
简单易用,不需要自定义 WebSocket 子协议。 | Token 暴露在 WebSocket 连接中,需要确保 WebSocket 连接的安全。 |
六、一些最佳实践
- 使用标准的 JWT 库: 不要自己实现 JWT 的生成和验证逻辑,使用成熟的 JWT 库 (例如 Firebase JWT) 可以避免安全漏洞。
- 将认证逻辑封装成中间件: 将认证逻辑封装成中间件,可以在多个 GraphQL Resolver 中复用,提高代码的可维护性。
- 使用 GraphQL 指令进行权限控制: 可以使用 GraphQL 指令来声明式的定义权限控制规则,简化代码,提高可读性。
- 监控和日志: 记录 API 的访问日志和错误日志,可以帮助我们及时发现和解决安全问题。
基于Token的Header与Subscription连接认证的核心思想和实现
通过 HTTP Header 传递 Token 进行身份验证适用于 GraphQL Query 和 Mutation,服务端从 Header 中提取 Token 并验证,通过上下文传递用户信息并进行权限控制。对于 GraphQL Subscription,则在 WebSocket 连接建立时,通过 connectionParams 传递 Token,服务端验证 Token 后,将用户信息保存在 WebSocket 连接对象中,并在后续的 Subscription 请求中使用。
希望今天的分享能够帮助大家更好地理解和应用 PHP GraphQL API 的身份验证。谢谢大家!