乐于分享
好东西不私藏

WordPress 插件如何对接授权服务器?(附完整可用代码)

WordPress 插件如何对接授权服务器?(附完整可用代码)

上篇分享了自建 WordPress 插件授权中心(服务器端),很多朋友问:服务器搭好了,插件客户端怎么对接?今天就手把手把客户端完整代码贴出来,复制即用,零门槛接入。

先说结论:整个对接只需要一个 PHP 类(约 300 行),搞定激活、停用、心跳复核、自动更新、秒级锁定全部功能。


一、对接原理(先看流程再抄代码)

客户端与服务器之间就 4 个动作,理解这张图就够了:

① 激活     设置页填授权码 ──activate──▶ 服务器记录「码 + 站点 + 插件」② 心跳     每小时 ──verify──▶ 服务器复核授权,无效则本地锁定③ 更新     WordPress 检查更新 ──check_update──▶ 返回新版本 + 下载地址(带令牌)④ 锁定     服务器停用/删除授权 ──webhook 推送──▶ 插件本地立即失效

安全设计(重点理解):

  • 服务器所有响应都带 HMAC-SHA256 签名,客户端收到后必须验签,防止中间人篡改、防止别人伪造服务器
  • 更新包的下载地址带时效令牌(24 小时有效),无法被无限转用
  • 服务器推送的锁定 webhook 带时间戳签名,防重放攻击

⚠️ 最容易踩的坑:验签时重新编码 JSON 必须用和服务器相同的参数
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
否则中文、斜杠被默认转义,签名永远对不上(代码里已处理)。


二、完整代码

第 1 步:客户端核心类

把下面的代码保存为 inc/license-client.php(放在你的插件目录下):

<?php/** * QM_License_Client —— WordPress 插件授权客户端 * 对接「插件授权中心」服务器(activate / deactivate / check_update / verify / plugin_info / webhook) */classQM_License_Client{    private $slug;        // 插件 slug,必须与服务器上传时一致    private $server_url;  // 服务器基础地址    private $secret;      // 与服务器 LS_SECRET 一致    private $name;        // 插件显示名    private $version;     // 当前版本号    private $main_file;   // 插件主文件(用于更新列表标识)    private $opt_key;     // 授权信息存储键    public function__construct($slug$args = array(){        $this->slug       = $slug;        $this->server_url = rtrim(isset($args['server_url']) ? $args['server_url'] : '''/');        $this->secret     = isset($args['secret']) ? $args['secret'] : '';        $this->name       = isset($args['name']) ? $args['name'] : $slug;        $this->version    = isset($args['version']) ? $args['version'] : '';        $this->main_file  = $slug . '/' . $slug . '.php';        $this->opt_key    = 'qm_license_' . $slug;        // 后台设置页        add_action('admin_menu'array($this'add_settings_page'));        add_action('admin_notices'array($this'show_notice'));        // 激活 / 停用端点        add_action('admin_post_qm_activate_' . $slugarray($this'handle_activate'));        add_action('admin_post_qm_deactivate_' . $slugarray($this'handle_deactivate'));        // 接收服务器推送的 webhook(授权被停用/删除 → 本地秒级锁定)        add_action('admin_post_wppl_revoke_' . $slugarray($this'handle_revoke'));        // 自动更新:更新检测 + 详情弹窗        add_filter('pre_set_site_transient_update_plugins'array($this'check_update'));        add_filter('plugins_api'array($this'plugin_info'), 103);        // 定时复核(每小时心跳,服务器判定失效则本地锁定)        add_action('qm_license_heartbeat_' . $slugarray($this'heartbeat'));        if (!wp_next_scheduled('qm_license_heartbeat_' . $slug)) {            wp_schedule_event(time() + 3600'hourly''qm_license_heartbeat_' . $slug);        }    }    // ============================================================ HTTP + 签名    /**     * 调用服务器 API,并校验响应 HMAC 签名。     * 关键点:服务器用 json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)     * 生成 data 字符串再签名;客户端必须用【相同选项】重新编码再比对。     */    private functionapi_call($action$params = array(){        $params['site_url']    = home_url();        $params['plugin_slug'] = $this->slug;        $resp = wp_remote_post($this->server_url . '/?action=' . $actionarray(            'timeout' => 20,            'body'    => $params,        ));        if (is_wp_error($resp)) {            return array('success' => false'error' => 'network''message' => $resp->get_error_message());        }        $json = json_decode(wp_remote_retrieve_body($resp), true);        if (!is_array($json) || !isset($json['data']) || !isset($json['sig'])) {            return array('success' => false'error' => 'bad_response''message' => '服务器响应异常');        }        $expect = hash_hmac('sha256'json_encode($json['data'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), $this->secret);        if (!hash_equals($expect$json['sig'])) {            return array('success' => false'error' => 'bad_sig''message' => '响应签名校验失败');        }        return $json['data'];    }    /** 服务器 reason 码 → 中文提示 */    private functionerr_text($r{        $map = array(            'invalid'              => '授权码无效',            'disabled'             => '授权已被停用',            'expired'              => '授权已过期',            'plugin_mismatch'      => '授权码与当前插件不匹配',            'no_instance'          => '该站点尚未激活此插件',            'instance_slug_mismatch' => '该授权码已绑定其它插件',            'limit_exceeded'       => '激活次数已达上限',            'bad_sig'              => '响应签名校验失败',            'network'              => '无法连接授权服务器',            'bad_response'         => '服务器响应异常',        );        $e = isset($r['error']) ? $r['error'] : '';        return isset($map[$e]) ? $map[$e] : (isset($r['message']) ? $r['message'] : '未知错误');    }    // ============================================================ 授权状态    /** 本地是否已激活(业务门控用) */    public functionis_active() {        $lic = get_option($this->opt_key);        return !empty($lic['key']);    }    /** 获取本地保存的授权信息 */    public functionget_license() {        return get_option($this->opt_key, array());    }    // ============================================================ 激活 / 停用    public functionhandle_activate() {        check_admin_referer('qm_activate_' . $this->slug);        $key = isset($_POST['license_key']) ? strtoupper(trim($_POST['license_key'])) : '';        if ($key === '') {            $this->notice('请填写授权码''error');            wp_safe_redirect(admin_url('options-general.php?page=qm-license-' . $this->slug));            exit;        }        $r = $this->api_call('activate'array('license_key' => $key));        if (!empty($r['success'])) {            update_option($this->opt_key, array(                'key'          => $key,                'activated_at' => current_time('mysql'),                'expires_at'   => isset($r['expires_at']) ? $r['expires_at'] : '',            ));            $left = (isset($r['activations_left']) && $r['activations_left'] !== null)                ? $r['activations_left'] . ' 个' : '不限';            $this->notice('激活成功!剩余可激活站点:' . $left);        } else {            $this->notice('激活失败:' . $this->err_text($r), 'error');        }        wp_safe_redirect(admin_url('options-general.php?page=qm-license-' . $this->slug));        exit;    }    public functionhandle_deactivate() {        check_admin_referer('qm_deactivate_' . $this->slug);        $lic = get_option($this->opt_key);        if (!empty($lic['key'])) {            $this->api_call('deactivate'array('license_key' => $lic['key']));        }        delete_option($this->opt_key);        $this->notice('已停用授权并解除本机绑定');        wp_safe_redirect(admin_url('options-general.php?page=qm-license-' . $this->slug));        exit;    }    // ============================================================ 复核心跳    public functionheartbeat() {        $lic = get_option($this->opt_key);        if (empty($lic['key'])) {            return;        }        $r = $this->api_call('verify'array('license_key' => $lic['key']));        if (empty($r['license_valid'])) {            delete_option($this->opt_key); // 服务器判定无效 → 本地锁定        }    }    // ============================================================ webhook 秒级锁定    public functionhandle_revoke() {        $slug = isset($_POST['slug']) ? $_POST['slug'] : '';        $key  = isset($_POST['license_key']) ? strtoupper(trim($_POST['license_key'])) : '';        $ts   = isset($_POST['ts']) ? intval($_POST['ts']) : 0;        $sig  = isset($_POST['sig']) ? $_POST['sig'] : '';        // 防重放:时间戳偏差超过 10 分钟直接拒绝        if ($ts < time() - 600 || $ts > time() + 600) {            wp_die('expired');        }        $expect = hash_hmac('sha256'$slug . '|' . $key . '|' . $ts$this->secret);        if (!hash_equals($expect$sig)) {            wp_die('bad sig');        }        $lic = get_option($this->opt_key);        if (!empty($lic['key']) && strtoupper($lic['key']) === $key) {            delete_option($this->opt_key);            update_option($this->opt_key . '_locked'current_time('mysql'));        }        wp_die('ok');    }    // ============================================================ 自动更新    public functioncheck_update($transient{        if (empty($transient) || !is_object($transient)) {            return $transient;        }        $lic = get_option($this->opt_key);        if (empty($lic['key'])) {            return $transient// 未激活不推送更新        }        $r = $this->api_call('check_update'array(            'license_key'     => $lic['key'],            'current_version' => $this->version,        ));        if (empty($r['license_valid'])) {            delete_option($this->opt_key); // 授权失效,不再提供更新            return $transient;        }        if (!empty($r['update'])) {            $u = $r['update'];            $obj = new stdClass();            $obj->slug          = $u['slug'];            $obj->plugin        = $this->main_file;            $obj->new_version   = $u['version'];            $obj->package       = $u['package']; // 已带下载令牌,有效期 24h            $obj->url           = isset($u['url']) ? $u['url'] : '';            $obj->requires      = isset($u['requires']) ? $u['requires'] : '';            $obj->requires_php  = isset($u['requires_php']) ? $u['requires_php'] : '';            $obj->tested        = isset($u['tested']) ? $u['tested'] : '';            $transient->response[$this->main_file] = $obj;        }        return $transient;    }    public functionplugin_info($res$action$args{        if ($action !== 'plugin_information' || empty($args->slug) || $args->slug !== $this->slug) {            return $res;        }        $lic = get_option($this->opt_key);        $r = $this->api_call('plugin_info'array(            'license_key' => isset($lic['key']) ? $lic['key'] : '',        ));        if (empty($r['success'])) {            return $res;        }        $sections = isset($r['sections']) ? $r['sections'] : array();        return (objectarray(            'slug'          => $r['slug'],            'name'          => $r['name'],            'version'       => $r['version'],            'requires'      => $r['requires'],            'requires_php'  => $r['requires_php'],            'tested'        => $r['tested'],            'homepage'      => $r['homepage'],            'sections'      => array(                'changelog'   => isset($sections['changelog']) ? $sections['changelog'] : '',                'description' => isset($sections['description']) ? $sections['description'] : '',            ),            'download_link' => '',        );    }    // ============================================================ 后台设置页    public functionadd_settings_page() {        add_options_page(            $this->name . ' 授权',            $this->name . ' 授权',            'manage_options',            'qm-license-' . $this->slug,            array($this'render_settings_page')        );    }    public functionrender_settings_page() {        $lic = get_option($this->opt_key);        $active = !empty($lic['key']);        echo '' . esc_html($this->name) . ' 授权管理';        if ($active) {            echo '授权状态:已激活'                . '授权码:' . esc_html($lic['key']) . ''                . (!empty($lic['expires_at']) ? ' | 到期时间:' . esc_html($lic['expires_at']) : ' | 永久有效')                . ' | 激活时间:' . esc_html($lic['activated_at']) . ''                . ''                . 'slug) . '”>'                . wp_nonce_field('qm_deactivate_' . $this->slug, '_wpnonce', true, false)                . '停用授权';        } else {            echo '当前未激活,填写授权码后即可正常使用插件并获得自动更新。'                . ''                . 'slug) . '”>'                . wp_nonce_field('qm_activate_' . $this->slug, '_wpnonce'truefalse)                . '授权码'                . ''                . ''                . '激活授权';        }        echo '';    }    // ============================================================ 提示消息    private functionnotice($msg$type = 'success'{        set_transient($this->opt_key . '_notice'array('m' => $msg't' => $type), 60);    }    public functionshow_notice() {        $n = get_transient($this->opt_key . '_notice');        if (!empty($n)) {            echo '' . esc_html($n['m']) . '';        }    }}

第 2 步:主文件接入(只需 3 行 + 一个门控)

在你插件的主文件(如 my-plugin.php)里加上:

<?php/** * Plugin Name: 我的插件 * Description: 带授权 + 自动更新 * Version: 1.0.0 */require_once __DIR__ . '/inc/license-client.php';$GLOBALS['qm_license'] = new QM_License_Client('my-plugin'array(    'server_url' => 'https://your-domain.com/license-server'// ① 你的服务器地址    'secret'     => '与服务器 config.php 的 LS_SECRET 保持一致'// ② 签名密钥    'name'       => '我的插件',    'version'    => '1.0.0'// ③ 当前版本号,务必与服务器上传的版本一致));// 业务门控:未激活时不加载核心功能$GLOBALS['qm_licensed'] = $GLOBALS['qm_license']->is_active();if (!$GLOBALS['qm_licensed']) {    add_action('admin_notices', function () {        echo '插件未激活,请到「设置 → 我的插件 授权」填写授权码。';    });}// ====== 以下写你的插件核心功能,在功能入口判断 $GLOBALS['qm_licensed'] 即可 ======

三、接入三步走

  1. 放代码:把 license-client.php 放到插件 inc/ 目录,主文件里 require + new QM_License_Client(...)
  2. 对齐三项server_url(服务器地址)、secret(与服务器 config.php 的 LS_SECRET 完全一致)、slug(与服务器上传插件时的 Slug 一致,注意大小写)
  3. 测试闭环:后台「设置 → 我的插件 授权」输入授权码 → 激活成功 → 服务器发布新版本 → WordPress 后台出现更新 → 点击更新自动下载安装 ✅

四、常见问题排查

现象
原因
解决
提示"响应签名校验失败"
secret
 与服务器不一致
两边密钥必须逐字一致
提示"该站点尚未激活此插件"
站点地址变化(http/https、www、端口)
重新激活;站点换域名需在服务器重置授权
激活后没有更新提示
slug 或版本号不一致
slug 必须与服务器上传时完全一致,版本号用服务器的版本号
停用了授权但插件还能用
等心跳或没收到 webhook
心跳 1 小时兜底;确认服务器能访问 wp-admin/admin-post.php
更新一直失败
下载令牌过期 / 服务器 URL 配置
检查 LS_SERVER_URL 是否为插件实际访问的地址

五、源码获取

本文完整源码(客户端类 + 接入示例 + 上期服务器端)已打包,关注公众号【公众号名称】,回复关键词「客户端代码」即可获取。

服务器端还没搭?看上一篇《自建WordPress插件授权中心,开源免费,全功能保姆级教程!》,回复「千猫授权中心」获取。

觉得有用,点赞、在看、转发给做 WordPress 的朋友,我们下期见!💪


文章首发于公众号【灵哥学社】,转载请联系授权。