You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

eLearning平台集成Zoho CRM:OAuth令牌与密钥安全处理问询

Zoho CRM OAuth 2.0 集成最佳实践与代码优化

针对你在eLearning平台集成Zoho CRM时遇到的OAuth 2.0令牌管理问题,以下是具体的解决方案和优化建议:

一、多用户令牌管理方案

  • 必须持久化到数据库:会话变量不可靠,会随会话过期、服务器重启丢失,且无法支持多用户跨会话场景,数据库存储是唯一可行方案。
  • 加密存储令牌:访问令牌、刷新令牌属于敏感数据,必须用AES-256等算法加密后存储,禁止明文保存;加密密钥需单独管理,不能和代码放在同一位置。
  • 关联用户ID:数据库表需包含user_id(关联eLearning平台用户)、access_token、refresh_token、expires_at(令牌过期时间戳)字段,确保每个用户的令牌独立管理。
  • 定期清理无效令牌:通过定时任务清理过期超过30天的令牌,减少数据冗余和安全风险。

二、自动令牌刷新实现逻辑

核心是调用API前先校验令牌有效性,自动触发刷新:

  1. 存储令牌时,计算并保存expires_at(当前时间 + 接口返回的expires_in秒数,建议提前5分钟视为过期,避免网络延迟导致的令牌失效)。
  2. 每次调用Zoho CRM API前,检查当前时间是否大于expires_at - 300(提前5分钟):
    • 若是,调用刷新令牌接口获取新的access_token和expires_in,更新数据库中的对应字段。
    • 若刷新令牌失效(接口返回错误),则引导用户重新授权。
  3. 将刷新逻辑封装为独立函数,确保所有API调用都经过该校验流程。

三、令牌撤销最佳实践

  • 触发时机:用户完成支付状态更新后、主动解除应用授权时、注销账号时,立即执行令牌撤销。
  • 执行步骤:
    1. 调用Zoho令牌撤销接口(POST https://accounts.zoho.com/oauth/v2/token/revoke),传入client_id、client_secret和要撤销的refresh_token。
    2. 撤销成功后,立即从数据库删除该用户的令牌记录。
  • 注意:撤销刷新令牌会同时使对应访问令牌失效,无需单独处理访问令牌。

四、认证密钥安全管理

  • 禁止硬编码:绝对不要把client_id和client_secret直接写在代码里,用环境变量存储。
  • 开发环境用.env文件:项目根目录创建.env文件,写入ZOHO_CLIENT_ID=xxx、ZOHO_CLIENT_SECRET=xxx,通过vlucas/phpdotenv库加载;同时将.env加入.gitignore,避免提交到版本控制。
  • 生产环境配置:在服务器上通过系统环境变量设置密钥(如Apache的SetEnv、Nginx的fastcgi_param),确保只有运行PHP进程的用户能读取这些变量。
  • 权限控制:服务器上的.env文件权限设置为600,仅所有者可读。

优化后的代码示例

1. 依赖安装(开发环境)

composer require vlucas/phpdotenv

2. 核心封装类

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

// 加载环境变量
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();

class ZohoCRMClient {
    private $clientId;
    private $clientSecret;
    private $redirectUri;
    private $dbConn;
    private $encryptionKey;

    public function __construct() {
        $this->clientId = $_ENV['ZOHO_CLIENT_ID'];
        $this->clientSecret = $_ENV['ZOHO_CLIENT_SECRET'];
        $this->redirectUri = $_ENV['ZOHO_REDIRECT_URI'];
        $this->encryptionKey = $_ENV['ENCRYPTION_KEY'];
        
        // 初始化MySQL连接
        $this->dbConn = new mysqli($_ENV['DB_HOST'], $_ENV['DB_USER'], $_ENV['DB_PASS'], $_ENV['DB_NAME']);
        if ($this->dbConn->connect_error) {
            die("数据库连接失败: " . $this->dbConn->connect_error);
        }
    }

    // 加密函数
    private function encrypt($data) {
        $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length('aes-256-cbc'));
        $encrypted = openssl_encrypt($data, 'aes-256-cbc', $this->encryptionKey, 0, $iv);
        return base64_encode($encrypted . '::' . $iv);
    }

    // 解密函数
    private function decrypt($data) {
        list($encryptedData, $iv) = explode('::', base64_decode($data), 2);
        return openssl_decrypt($encryptedData, 'aes-256-cbc', $this->encryptionKey, 0, $iv);
    }

    // 用授权码获取初始令牌并存储
    public function getAndStoreToken($userId, $authCode) {
        $url = 'https://accounts.zoho.com/oauth/v2/token';
        $postFields = [
            'client_id' => $this->clientId,
            'client_secret' => $this->clientSecret,
            'redirect_uri' => $this->redirectUri,
            'code' => $authCode,
            'grant_type' => 'authorization_code'
        ];

        $response = $this->makeCurlRequest($url, 'POST', $postFields);
        $tokenData = json_decode($response, true);

        if (isset($tokenData['access_token'], $tokenData['refresh_token'], $tokenData['expires_in'])) {
            $accessToken = $this->encrypt($tokenData['access_token']);
            $refreshToken = $this->encrypt($tokenData['refresh_token']);
            $expiresAt = time() + $tokenData['expires_in'];

            // 存储/更新令牌
            $stmt = $this->dbConn->prepare("REPLACE INTO zoho_tokens (user_id, access_token, refresh_token, expires_at) VALUES (?, ?, ?, ?)");
            $stmt->bind_param("issi", $userId, $accessToken, $refreshToken, $expiresAt);
            $stmt->execute();
            $stmt->close();

            return $tokenData['access_token'];
        }

        throw new Exception("获取令牌失败: " . json_encode($tokenData));
    }

    // 获取有效访问令牌(自动刷新)
    public function getValidAccessToken($userId) {
        $stmt = $this->dbConn->prepare("SELECT access_token, refresh_token, expires_at FROM zoho_tokens WHERE user_id = ?");
        $stmt->bind_param("i", $userId);
        $stmt->execute();
        $result = $stmt->get_result();
        $tokenRecord = $result->fetch_assoc();
        $stmt->close();

        if (!$tokenRecord) {
            throw new Exception("用户未授权Zoho CRM");
        }

        $accessToken = $this->decrypt($tokenRecord['access_token']);
        $refreshToken = $this->decrypt($tokenRecord['refresh_token']);
        $expiresAt = $tokenRecord['expires_at'];

        // 提前5分钟刷新令牌
        if (time() >= $expiresAt - 300) {
            $newTokenData = $this->refreshToken($refreshToken);
            $newAccessToken = $newTokenData['access_token'];
            $newExpiresAt = time() + $newTokenData['expires_in'];

            // 更新数据库
            $stmt = $this->dbConn->prepare("UPDATE zoho_tokens SET access_token = ?, expires_at = ? WHERE user_id = ?");
            $stmt->bind_param("sii", $this->encrypt($newAccessToken), $newExpiresAt, $userId);
            $stmt->execute();
            $stmt->close();

            return $newAccessToken;
        }

        return $accessToken;
    }

    // 刷新令牌
    private function refreshToken($refreshToken) {
        $url = 'https://accounts.zoho.com/oauth/v2/token';
        $postFields = [
            'client_id' => $this->clientId,
            'client_secret' => $this->clientSecret,
            'refresh_token' => $refreshToken,
            'grant_type' => 'refresh_token'
        ];

        $response = $this->makeCurlRequest($url, 'POST', $postFields);
        $tokenData = json_decode($response, true);

        if (!isset($tokenData['access_token'], $tokenData['expires_in'])) {
            throw new Exception("刷新令牌失败: " . json_encode($tokenData));
        }

        return $tokenData;
    }

    // 调用Zoho CRM API
    public function callCRMAPI($userId, $url, $method = 'GET', $data = []) {
        $accessToken = $this->getValidAccessToken($userId);
        $headers = [
            'Authorization: Bearer ' . $accessToken,
            'Content-Type: application/json'
        ];

        return $this->makeCurlRequest($url, $method, $data, $headers);
    }

    // 撤销令牌
    public function revokeToken($userId) {
        $stmt = $this->dbConn->prepare("SELECT refresh_token FROM zoho_tokens WHERE user_id = ?");
        $stmt->bind_param("i", $userId);
        $stmt->execute();
        $result = $stmt->get_result();
        $tokenRecord = $result->fetch_assoc();
        $stmt->close();

        if (!$tokenRecord) {
            return true;
        }

        $refreshToken = $this->decrypt($tokenRecord['refresh_token']);

        // 调用Zoho撤销接口
        $url = 'https://accounts.zoho.com/oauth/v2/token/revoke';
        $postFields = [
            'client_id' => $this->clientId,
            'client_secret' => $this->clientSecret,
            'token' => $refreshToken
        ];

        $this->makeCurlRequest($url, 'POST', $postFields);

        // 删除本地令牌记录
        $stmt = $this->dbConn->prepare("DELETE FROM zoho_tokens WHERE user_id = ?");
        $stmt->bind_param("i", $userId);
        $stmt->execute();
        $stmt->close();

        return true;
    }

    // 通用CURL请求封装
    private function makeCurlRequest($url, $method = 'GET', $data = [], $headers = []) {
        $curl = curl_init();
        $options = [
            CURLOPT_URL => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_ENCODING => '',
            CURLOPT_MAXREDIRS => 10,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_HTTPHEADER => $headers,
        ];

        if ($method === 'POST' && !empty($data)) {
            if (in_array('Content-Type: application/json', $headers)) {
                $options[CURLOPT_POSTFIELDS] = json_encode($data);
            } else {
                $options[CURLOPT_POSTFIELDS] = http_build_query($data);
            }
        }

        curl_setopt_array($curl, $options);
        $response = curl_exec($curl);

        if (curl_errno($curl)) {
            throw new Exception("CURL错误: " . curl_error($curl));
        }

        curl_close($curl);
        return $response;
    }
}

// 使用示例
try {
    $zohoClient = new ZohoCRMClient();
    // 1. 用户授权后,用授权码获取令牌(示例userId为1)
    // $zohoClient->getAndStoreToken(1, '用户返回的授权码');

    // 2. 调用CRM API更新支付状态
    $updateUrl = 'https://www.zohoapis.com/crm/v5/Leads/{lead_id}';
    $updateData = [
        'data' => [
            [
                'Payment_Status' => '已支付'
            ]
        ]
    ];
    $response = $zohoClient->callCRMAPI(1, $updateUrl, 'PUT', $updateData);
    print_r(json_decode($response, true));

    // 3. 完成操作后撤销令牌
    // $zohoClient->revokeToken(1);
} catch (Exception $e) {
    echo "错误: " . $e->getMessage();
}
?>

3. 数据库表结构示例

CREATE TABLE zoho_tokens (
    user_id INT PRIMARY KEY,
    access_token TEXT NOT NULL,
    refresh_token TEXT NOT NULL,
    expires_at INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

内容的提问来源于stack exchange,提问作者Shibiliya Ismail

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.09 18:40:56