PHP GraphQL API的身份验证:实现基于Token的Header与Subscription连接认证

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 添加到 Authorization Header 中。
  • 服务端: 在 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'] 获取 Authorization Header 的值,并解析出 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. 关键点

  • connectionParamsWebSocketLinkoptions 中,设置 connectionParams,将 Token 传递给服务器。
  • 服务器端处理 connection_init 消息: 服务器需要监听 connection_init 消息,从 payload 中提取 Token,并进行验证。
  • 保存用户身份信息: 验证成功后,将用户身份信息保存在 WebSocket 连接对象中,以便在后续的 Subscription 请求中使用。
  • 权限控制: 在 Subscription 的 resolvesubscribe 函数中,根据用户身份信息进行权限控制。
  • 发送 connection_ackconnection_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 的身份验证。谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注