AuthLink 对接教程

AUTHLINK · 通用对接教程

任何网站,按这份教程都能接上授权

AuthLink 的接入面只有两个:一份 把站点和服务端绑起来的凭据(license.dat), 和一个 永不离开服务器的密钥(app_key)。无论你的站点是 PHP、Node、 Python、Go、还是静态页 + 一个后端接口,只要会发 HTTP 请求、会解密 AES-256-GCM, 就能在半小时内把授权跑通。本页给出 PHP 复制即用 与 任意语言手写 两条路径。

01授权怎么跑起来的

先说清三方关系,后面所有步骤都是它的展开。AuthLink 由三个角色组成, 各司其职,不需要你改造站点架构,只要在合适的位置放一个「授权判定」的调用。

🖥

授权服务端

你部署的 AuthLink 服务端。持有主密钥,负责签发授权码、判定激活/查询/心跳、 签发加密凭据。判定逻辑全在这里,站点改不了。

🌐

你的站点

要接入授权的网站。只保存两样东西:app_key(服务器端)与 license.dat(加密凭据)。本地只做「能不能解密 + 域名对不对 + 有没有过期」。

🧑‍💻

浏览器 / 访客

只看到提示层与后台授权卡片。永远接触不到 app_key,也拿不到授权码—— 签名与解密全部在站点服务器进程内完成。

核心凭据 license.dat 是什么

站点首次激活成功后,服务端会签发一份 AES-256-GCM 加密的凭据, 站点把它原样存成 license.dat(建议 0640 权限)。它有两条硬性质:

  • 密钥只由 app_key 派生(HKDF-SHA256 ⇒ key_license), 所以站点不需要主密钥也能自校验。
  • 域名被绑进认证标签(AAD = "license.dat|" + 域名)。 换个域名、或改动密文任何一个字节,解密都会直接失败 —— 复制到别的站等于一张废纸。
📌
因此「授权判定」在本地的含义非常窄:能解密 + 域名匹配 + 未过期。 它判不了「服务端是否已把这张码禁用」——那要靠 心跳(每小时一次)向服务端确认。 两者配合:本地拦得住搬运与篡改,心跳拦得住服务端的后续吊销。

三个你一定会用到的服务端接口

接口作用是否需签名
POST /api/license/activate拿授权码换取 license_b64(首次激活)是
GET /api/license/info查询某域名的授权状态(后台「查询授权」用)是
GET /api/license/heartbeat心跳续期确认,连续 3 次失败服务端标记异常是

「需签名」= 请求要带 sign、timestamp、nonce 三个字段, 算法见 5.2 节。PHP 站点不用自己算 —— SDK 与同源代理都替你算好了。

02接入前置准备

动手前,先确认这三样东西已经在你手上。

  1. 一个已部署的 AuthLink 服务端 按《安装部署.md》跑起来,能访问 https://你的域名/healthz 并返回 {"code":0,"data":{"status":"ok",...}}。安装向导最后会下发一个 app_key。
  2. 安装向导下发的 app_key(ak_ 开头) 这是本站点的签名密钥,只放在站点服务器上,绝不能进前端、绝不能提交到 Git。 丢失它=本站点再也无法签名,只能回服务端重新生成。
  3. 站点对外域名(或固定公网 IP) 授权是绑定身份的:按域名授权填 blog.example.com;按 IP 授权填 203.0.113.24。 域名会被归一化(剥协议/端口/www、转小写),子域自动匹配主域名。
⚠️
先把「身份」想清楚再领码。授权码领取时就把域名/IP 写死了,之后 activate 会严格校验。开发阶段用 localhost 或内网 IP 试通, 上线换正式域名时按正式域名重新领一张码即可,不要指望改 AAD 能复用旧码。

03授权码从哪来(三种方式)

授权码形如 CXXX-XXXX-XXXX-XXXX,是把某个域名/IP 与你的产品关联起来的一次性凭据。 三种获取方式按角色选用。

🧑

① 用户中心自助领取

注册账户 → 进「用户中心」→ 选「按域名授权」或「按 IP 授权」→ 免费领取。 适合分发给最终站长,受单账户配额限制(默认 3 张)。

🛠

② 管理端签发

管理员在管理端直接为指定域名签发/管理授权。适合你作为作者统一控制、 批量给客户开授权。

📴

③ 离线签发

客户服务器无法出网时用。管理端「离线签发」生成一段 license_b64, 客户在后台授权面板「离线导入」粘贴即完成。全程零网络请求。

方式 ③ 的产物与在线激活完全同构

离线签发刻意复用了同一套 license.dat 格式(同密钥、同 AAD), 所以客户端校验代码一行都不用改。唯一的区别是凭据由管理员手工传递, 而不是经由 /api/license/activate 在线换取。安全性也没打折: 离线凭据的 AAD 仍然绑定域名,复制到别的域名必然解密失败。

💡
三种方式产出的凭据在站点侧表现完全一致。你的接入代码只需处理「已授权 / 未授权」两种状态, 不必区分码是怎么来的。

04方式一:PHP 站点接入(复制即用)

AuthLink 自带一个单文件、零 composer 依赖的 PHP SDK。 如果你的站点是 PHP(EMLOG / WordPress / 自研都行),照下面六步做完即可。整套逻辑由 license-client.php(SDK)+ auth-proxy.php(同源签名代理) 两个文件承担,你不需要自己写签名和 AES 代码。

4.1 复制四个文件到站点

把服务端安装包 client/ 目录里的文件复制到站点可写目录(示例放在 站点根下的 auth/ 与站点根):

目录结构
你的站点/
├── auth/
│   └── license-client.php   ← client/license-client.php   # SDK(签名 / 解密 / 面板)
├── auth-proxy.php           ← client/auth-proxy.php         # 同源签名代理(浏览器调它)
├── auth-panel.css           ← client/assets/auth-panel.css  # 授权面板 + 前台提示层样式
└── auth-panel.js            ← client/assets/auth-panel.js   # 授权面板 + 心跳/提示层逻辑
⚠️
auth-panel.css / auth-panel.js 的文件名与位置不可改 —— SDK 的 siteAssets() 以相对路径引用它们,且必须放在站点可公开访问的目录。 其余文件建议放进 auth/ 并禁止外部访问(见 七、安全须知)。

4.2 写站点配置 config.local.php

把 client/config.example.php 复制成 auth-proxy.php 同目录下的 config.local.php,填入三项:

config.local.php
<?php
return [
    'server_url' => 'https://auth.example.com',        // 你的 AuthLink 服务端地址(不带末尾斜杠)
    'app_key'    => 'ak_安装向导下发的密钥',            // ★ 只放服务器端,绝不进前端
    'dat_file'   => __DIR__ . '/storage/license.dat',  // 凭据保存路径(建议移出 web 根)
    'product'    => 'default',                         // 产品标识,与你领取授权码时填的一致即可
];

config.local.php 含明文密钥,必须让 web 服务器拒绝外部访问 (nginx deny all; 或 Apache <Files> 规则)。

4.3 站点入口引入 SDK

在站点每个 PHP 请求都会经过的公共入口(EMLOG 是 header.php; 其它框架就是你的 bootstrap / 中间件)加一行:

站点入口(如 header.php)
<?php
require_once __DIR__ . '/auth/license-client.php';
LH_Client::init([
    'server'   => 'https://auth.example.com',
    'app_key'  => 'ak_安装向导下发的密钥',
    'dat_file' => __DIR__ . '/storage/license.dat',
    'product'  => 'default',
    'claim_url'=> 'https://auth.example.com/user.html',  // 未授权时「免费领取」按钮指向
]);
📌
此处可在 init() 里直接填密钥,也可以只 require 一个读取 config.local.php 的引导片段。原则只有一个:密钥只出现在服务器端 PHP 文件里。

4.4 前台输出授权资源

在站点每个前台页面的公共页脚(EMLOG 是 footer.php,放在其它插件脚本 之前)输出一次资源引用与提示容器:

前台页脚(如 footer.php)
<?php echo LH_Client::siteAssets(); ?>

siteAssets() 只输出三样东西: <link auth-panel.css>、<script auth-panel.js defer>、 <div id="auth-hint">。它不含任何授权详情, 前台 JS 也不会渲染授权码 / 域名 / 到期时间 —— 这些只在管理员后台能看到。

4.5 后台挂「授权设置」面板

在站点后台的设置页(EMLOG 模板是 options.php)挂入授权面板的承载项, 让管理员能看到授权卡片并输入授权码。核心是输出一段包含 LH_Client::boot() 的 HTML:

后台设置页承载项
<?php
// 放在后台设置页的某个分组下(EMLOG 模板:$options 数组的一个承载项)
echo '<link rel="stylesheet" href="auth-panel.css">'
   . '<script src="auth-panel.js" defer></script>'
   . LH_Client::boot();
💡
如果你的后台不在站点根下(例如在 /admin/),相对路径要相应写成 ../auth-panel.css。EMLOG 的模板承载项细节(分组导航、无 name 输入框的结构性保证) 见《PureBlog对接.md》第三节。

4.6 激活 + 心跳

管理端进「授权设置」,粘贴授权码点「立即授权」,SDK 会完成 签名 → 请求 activate → 解密 license_b64 → 校验域名 → 写 license.dat 整条链路。 之后本地判定用:

本地判定与心跳
<?php
// 本地判定:能解密 + 域名匹配 + 未过期(无网络请求,毫秒级)
if (!LH_Client::isLicensed()) {
    // 未授权 —— 可渲染阻断页、隐藏付费内容、或仅提示
    echo LH_Client::blockGuard();   // 输出阻断页/提示层
}

// 心跳:建议用 crontab 每小时执行一次(不阻塞页面请求)
// 0 * * * * php /path/to/站点/cron-heartbeat.php
LH_Client::heartbeat();
⚠️
心跳不要放在每个前台请求里同步执行 —— 它会发起网络请求,会拖慢页面。 AuthLink 的前台提示层(auth-panel.js)本身就会在页面加载时自动心跳一次, 服务端侧再配一个每小时 crontab 即可双保险。

4.7 方式一验收

  • 后台设置页出现授权卡片,初始显示「❌ 未授权」。
  • 填入有效授权码点「立即授权」,卡片变为「✅ 已授权」,授权时间 / 域名 / 到期时间 / 指纹四项齐全。
  • 数据库里查不到授权码(授权码输入框故意没有 name,结构性不落库)。
  • 前台源码里搜不到授权码 / 域名 / 到期时间 / 指纹。
  • 站点根出现 storage/license.dat,权限为 0640。
  • 把 license.dat 复制到另一个域名,isLicensed() 返回 false(AAD 不匹配)。

05方式二:任意语言接入(HTTP API)

站点不是 PHP?完全没问题 —— 授权协议本身就是纯 HTTP + 标准密码学原语。 Node / Python / Go / Java / .NET / 甚至一个 shell 脚本,只要能发 HTTPS 请求、能做 HMAC-SHA256、能解 AES-256-GCM,就能接入。本节给出完整算法与可运行的参考实现。

5.1 你需要实现的四件事

①

规范化 JSON

把业务参数按键名递归字典序排序后序列化为紧凑 JSON(见 5.2)。

②

HMAC 签名

用 app_key 对 payload 做 HMAC-SHA256,取 hex 小写。

③

调三个接口

activate / info / heartbeat,带上签名三件套。

④

解密凭据

HKDF 派生密钥 → AES-256-GCM 解密(AAD 带域名)→ 存成 license.dat。

5.2 请求签名算法

签名覆盖「方法 + 路径 + 时间戳 + 一次性随机数 + 业务参数摘要」五项, 业务参数里不含 sign / timestamp / nonce 本身。服务端会把你发来的业务字段原样重新规范化后验签。

签名算法(伪代码)
canonical = canonicalJson(业务参数)        # 键名递归字典序排序,紧凑 JSON,不转义斜杠/Unicode
bodyHash  = sha256_hex(canonical)
payload   = METHOD + "\n" + PATH + "\n" + timestamp + "\n" + nonce + "\n" + bodyHash
sign      = hmac_sha256_hex(app_key, payload)

# 规则:
#   METHOD    大写("POST" / "GET")
#   PATH      不含查询串的路径,如 "/api/license/activate"
#   timestamp Unix 秒字符串,服务端允许 ±300 秒
#   nonce     一次性随机串(长度 ≤ 64),服务端内存 + 落库双重去重防重放
#   签名失败 → HTTP 403 + code 1007
业务参数与请求方式
接口方法 / 路径业务参数(参与签名)参数位置
激活POST /api/license/activate license_code、domain、client_ipJSON body
查询GET /api/license/info domain(可选 ip、version、integrity)query string
心跳GET /api/license/heartbeat domain(可选 ip、version、integrity)query string
📌
activation 的 client_ip 用于 ip_limit 校验与首次 IP 绑定; 非 IP 授权场景可传空串。info/heartbeat 的 ip 可选,用于让按 IP 绑定的授权 通过 IP 回退匹配。
version / integrity 同样是可选(v1.4.0):不传就不会被判篡改, 传了才参与服务端权威指纹比对(见第 06 节)。

5.3 参考实现:Node.js

authelink.mjs(Node ≥ 18)
import crypto from 'node:crypto';

const SERVER  = 'https://auth.example.com';
const APP_KEY = process.env.AUTHLINK_APP_KEY;   // ak_xxx,只放服务器端

/* 1) 规范化 JSON:键名递归字典序 */
function canonicalJson(v) {
  if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']';
  if (v && typeof v === 'object') {
    return '{' + Object.keys(v).sort()
      .map(k => JSON.stringify(k) + ':' + canonicalJson(v[k])).join(',') + '}';
  }
  return JSON.stringify(v);
}

/* 2) 带签名请求 */
function signParams(method, path, params) {
  const ts = String(Math.floor(Date.now() / 1000));
  const nonce = crypto.randomBytes(16).toString('hex');
  const bodyHash = crypto.createHash('sha256').update(canonicalJson(params)).digest('hex');
  const payload = [method.toUpperCase(), path, ts, nonce, bodyHash].join('\n');
  const sign = crypto.createHmac('sha256', APP_KEY).update(payload).digest('hex');
  return { ...params, sign, timestamp: ts, nonce };
}

/* 3) 激活:换取 license_b64 */
async function activate(licenseCode, domain, clientIp = '') {
  const path = '/api/license/activate';
  const body = signParams('POST', path, {
    license_code: licenseCode,
    domain,
    client_ip: clientIp,
  });
  const res = await fetch(SERVER + path, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  const json = await res.json();
  if (json.code !== 0) throw new Error(json.message);   // 业务失败 HTTP 200 + code≠0
  // 4) 解密并落盘
  const payload = decryptLicense(json.data.license_b64, domain);
  return payload;
}

/* 4) 解密 license.dat */
function deriveLicenseKey(appKey) {
  return Buffer.from(crypto.hkdfSync(
    'sha256', Buffer.from(appKey),
    Buffer.from('AuthLink.license.v1'), Buffer.from('license.dat'), 32));
}
function b64u(s) { return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); }
function decryptLicense(b64, domain) {
  const [ver, iv, tag, ct] = b64.split('.');
  if (ver !== 'v1') throw new Error('凭据版本不支持');
  const d = crypto.createDecipheriv('aes-256-gcm', deriveLicenseKey(APP_KEY), b64u(iv));
  d.setAuthTag(b64u(tag));
  d.setAAD(Buffer.from('license.dat|' + domain, 'utf8'));   // ★ AAD 绑定域名
  return JSON.parse(Buffer.concat([d.update(b64u(ct)), d.final()]).toString('utf8'));
}

/* 用法 */
const info = await activate('CXXX-XXXX-XXXX-XXXX', 'blog.example.com');
console.log('授权到期:', info.expire_time, '指纹:', info.fingerprint);

5.4 参考实现:Python

签名只需标准库;解密 AES-256-GCM 需要第三方库 pip install cryptography(这是唯一的外部依赖)。

authlink.py
import base64, hashlib, hmac, json, os, secrets, time, urllib.request

SERVER  = "https://auth.example.com"
APP_KEY = os.environ["AUTHLINK_APP_KEY"]

def canonical_json(obj):
    # 键名递归字典序 + 紧凑分隔符;ensure_ascii=False 对齐 PHP 的 UNESCAPED_UNICODE
    return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)

def signed_params(method, path, params):
    ts    = str(int(time.time()))
    nonce = secrets.token_hex(16)
    body_hash = hashlib.sha256(canonical_json(params).encode()).hexdigest()
    payload = "\n".join([method.upper(), path, ts, nonce, body_hash])
    sign = hmac.new(APP_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()
    return {**params, "sign": sign, "timestamp": ts, "nonce": nonce}

def _post_json(path, body):
    req = urllib.request.Request(
        SERVER + path, data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=8) as r:
        return json.loads(r.read().decode())

# ---------- 解密 license.dat ----------
def _b64u(s):
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def derive_license_key(app_key: str) -> bytes:
    # HKDF-SHA256(ikm=app_key, salt="AuthLink.license.v1", info="license.dat", len=32)
    prk = hmac.new(b"AuthLink.license.v1", app_key.encode(), hashlib.sha256).digest()
    t = okm = b""; i = 1
    while len(okm) < 32:
        t = hmac.new(prk, t + b"license.dat" + bytes([i]), hashlib.sha256).digest()
        okm += t; i += 1
    return okm[:32]

def decrypt_license(b64: str, domain: str) -> dict:
    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
    ver, iv_b, tag_b, ct_b = b64.split(".")
    assert ver == "v1", "凭据版本不支持"
    aes = AESGCM(derive_license_key(APP_KEY))
    plain = aes.decrypt(_b64u(iv_b), _b64u(ct_b) + _b64u(tag_b),
                        ("license.dat|" + domain).encode())
    return json.loads(plain)

# ---------- 激活 ----------
def activate(license_code: str, domain: str, client_ip: str = "") -> dict:
    path = "/api/license/activate"
    body = signed_params("POST", path, {
        "license_code": license_code, "domain": domain, "client_ip": client_ip})
    resp = _post_json(path, body)
    if resp["code"] != 0:
        raise RuntimeError(resp["message"])
    return decrypt_license(resp["data"]["license_b64"], domain)

if __name__ == "__main__":
    info = activate("CXXX-XXXX-XXXX-XXXX", "blog.example.com")
    print("已授权:", info["domain"], "到期", info["expire_time"])

5.5 之后要做的两件事

  • 落盘凭据:把解密成功的明文 JSON 与原始 license_b64 存下来(原始密文写 license.dat,0640 权限)。本地判定时只需重新 decryptLicense(读到的 b64, 当前域名) —— 能解出、域名对、expire_time 大于当前时间,即为已授权。
  • 起一个心跳定时任务:每小时调用一次 GET /api/license/heartbeat(同样带签名)。服务端连续 3 次收不到或返回异常, 会把该授权标记为异常(status=2)。
💡
想省掉自己写签名/解密?PHP 站点直接用方式一的 SDK;非 PHP 站点也可以把 auth-proxy.php 当作一个「本地签名小服务」——它已封装好签名与落盘, 你只要 POST auth-proxy.php?action=activate 即可,浏览器永远看不到 app_key。

06状态码语义速查

所有接口统一返回 {"code":0,"message":"...","data":...}。 业务失败一律 HTTP 200 + 非 0 code;只有签名失败用 403、 限流用 429、服务器错误用 500。接入时请按 code 判分支, 不要按 HTTP 状态判。

code含义接入侧建议
0成功正常处理 data
1001参数错误检查字段名 / 值
1002用户名或密码错误(登录相关)
1003 / 1004用户名 / 邮箱已存在(注册相关)
1005账号禁用(登录相关)
1006未登录 / 令牌失效(需登录的接口)
1007权限不足 / 签名失败签名失败是 HTTP 403 + 1007:核对 app_key、时间戳 ±300s、canonical 规则
1010邮箱验证码不正确或已过期(注册相关)
1011邮件服务未配置(管理端未填 SMTP)
1012验证码获取过于频繁(注册相关)
1013验证码发送失败(注册相关)
2001授权码不存在吊销:删除本地 license.dat
2002已禁用吊销:删除本地 license.dat
2003已过期本地已按 expire_time 判 false,不删凭据
2004域名不匹配可能换域名,不删凭据
2005IP 不匹配核对 ip_limit 与 client_ip
2006心跳异常(连续失败 3 次被标记)管理员改回 status=1 或重新激活
2007授权码已被其它域名占用按绑定域名重新领码
2008超出领取配额(领取授权码相关)
2009已被拉黑优先于一切授权状态;SDK 渲染拉黑版阻断页
2010文件完整性校验失败(疑似破解版)优先于授权状态;按「被篡改」处置,别当未授权
2011恢复凭据签发失败(管理端签发相关)
9999内部错误按「连接故障」处理(含联不上服务器)
⛔
2009(拉黑)是「一票否决」:即使本地 license.dat 仍能解密, 也必须立即拦下。拉黑由管理员显式处置,独立于授权状态机, 接入方不该把它和「未授权」混为一谈。
🛡
2010(完整性)同样是「一票否决」,且优先于授权状态:它意味着站点文件 与作者发布的版本不一致(被删/被改)。服务端判定顺序固定为 拉黑 2009 → 完整性 2010 → 查授权;接入方收到 2010 应停止对外提供页面, 而不是走「未授权」那套提示层。
可选上报:在心跳 / info 的签名参数里附带 version(你的版本号)与 integrity(本地实算整包指纹, 64 位 sha256 hex)即可接入这一层;不上报不会被判篡改(未登记版本一律放行)。
处置边界(v1.4.1 明确):收到 2010 只应阻断前台(写标记 + 提示), 绝不能删除本地 license.dat ——「文件疑似被改」与「授权是否有效」 是两套独立语义,删凭据会把「疑似破解」直接变成「已授权站点变未授权」, 且因文件一直不一致而永远无法自愈。另外:站点自己的配置文件 (如 config.local.php:server_url / app_key)不属于受控文件, 站长修改它不该被判定为篡改。
⚠
接入方判定「未授权」必须用白名单,不能用范围(v1.4.2 / 模板 v2.0.160 真实事故):曾经有客户端写成「code 落在 2000~2999 就算未授权」, 结果 2009 / 2010 / 2011 这些与授权无关的错误,也弹出了「本站未授权, 请输入授权码完成激活」,把站长引向重新激活这个无效动作。正确判据只有 一处来源:code ∈ {2001,2002,2003,2004,2005,2006,2007,2008} 才叫未授权;其余 code 各走各的专属处置(见上表)。
⚠
运行时查询必须带 product(v1.4.3):服务端发布指纹表的 唯一键是「产品 + 版本」,并不是版本号单独唯一。只按 version 查会被其它产品的同名版本遮蔽(后登记那行 id 更大、先被取到) → 指纹自然不符 → 正常站点被判 2010 整站阻断。正确做法: info / heartbeat 都带上 product; 服务端在无法唯一定位真值时应当放行(漏判优于误杀)。
✓
管理端的开关要「立即生效」,站点必须自己主动刷新(v1.4.3):服务端把某站点 禁用 / 启用 / 拉黑 / 修正发布指纹登记之后,站点并不会自动知道。正确做法是在 页面渲染之前做一次带节流(建议 60 秒)的状态查询,把最新状态落到 本地标记 —— 否则要等下一个访客、或只能靠异步 JS 心跳那一轮才可见。同时两个坑: ① 清 / 写本地标记后必须让本进程内的判定缓存失效,否则同一请求内仍拿旧结论 (表现为「作者已修正,站长第一次访问还是阻断页,刷新第二次才好」); ② 阻断页是终止页,里面要放一个自愈探针(加载时 + 之后每 15 秒问一次, 恢复正常就自动重载),否则服务端后来改口也永远传不到站点 —— 死锁。

07安全须知(三条红线)

授权能否立得住,取决于下面三件事有没有做对。

红线一:app_key 永不进前端

app_key 一旦泄露到浏览器,任何人都能冒充你的站点签名。 正确做法:签名发生在站点服务器进程内(SDK 或同源代理 auth-proxy.php), 浏览器只发业务参数、拿结果。前端 JS 里不得出现 app_key 字符串。

红线二:auth/ 目录与 config.local.php 拒绝外部访问

把密钥与 SDK 放进不可直接访问的目录,并显式封死:

nginx
location ^~ /auth/            { deny all; }
location = /auth-proxy.php    { allow all; }   # 代理需要被浏览器访问,单独放行
location ~* config\.local\.php { deny all; }
Apache(.htaccess)
<Files "config.local.php">
  Require all denied
</Files>

红线三:license.dat 放在 web 根之外,权限 0640

凭据虽是密文,但被下载走也没有必要。放到 storage/ 一类 不对外映射的目录,权限设为 0640(SDK 写入时会自动 chmod 0640)。

🛡
为什么复制走 license.dat 也没用?因为它用 AES-256-GCM 加密,且 域名被写进了认证标签(AAD)。换一个域名解密,认证标签校验直接失败 —— 这就是「打不开也带不走」的技术根据。

08常见问题

Q:我的站点没有「后台设置页」,能接吗?

能。授权面板只是方便管理员输入授权码的一种「UI」。 你也可以完全跳过 4.5,改为在服务器上用脚本调用激活:把授权码传给 LH_Client::activate($code)(PHP)或直接走 5.3 / 5.4 的 HTTP 激活接口即可。 授权与「有没有后台界面」无关。

Q:我想接入的其实是「用户注册」,不是模板授权?

AuthLink 同时提供一套账户体系(注册 / 登录 / 令牌)。 这是与模板授权两条独立的产品线:授权走 /api/license/*(签名鉴权), 账户走 /api/auth/*(登录态鉴权)。本教程聚焦授权接入; 账户接口见服务端《API.md》第 1–3 节。

Q:签名一直返回 403 / 1007,怎么排查?

  • 时间戳:服务器与本机时钟是否同步?窗口只有 ±300 秒。
  • canonical:键名有没有递归排序?有没有多/少字段? JSON 是否紧凑(无多余空格)?
  • PATH:必须是 /api/license/activate 这种完整路径, 不含 host、不含 query。
  • app_key:是否用了新装的、别站点的、或带多余空白的密钥?
  • nonce:同一个 nonce 用过一次就会被判重放,必须每次新生成。

Q:授权码换了域名后失效了?

这是预期行为。授权码在领取/签发时就绑定了一个身份(域名或 IP), 换域名等于换身份。按新域名重新领一张即可。归一化规则:小写、剥协议/端口/www.、 子域自动匹配主域。

Q:客户服务器无法出网,怎么授权?

用管理端的「📴 离线签发」,把生成的 license_b64 交给客户, 客户在后台授权面板「离线导入」粘贴即可。导入过程全程零网络请求, 但凭据与在线激活同构,本地校验逻辑完全一致。

Q:本地判定通过,但服务端已经把码禁用了怎么办?

靠心跳兜底。站点每小时心跳一次,服务端若判定码已禁用/过期, 会返回对应的业务错误;SDK 收到 2001 / 2002 会 立即删除本地 license.dat(无需等过期、无需管理员手动操作)。 好消息是这不需要你写代码 —— 方式一的 SDK 已经内置。

09接入验收清单

不管走哪条路径,最终都应收敛到这份可核对的结果。

  • 服务端连通:服务端 /healthz 返回 code:0。
  • 激活成功:activate 返回 code:0,data.license_b64 非空。
  • 凭据落盘:站点出现 license.dat,权限 0640,内容为 v1.iv.tag.ct 四段。
  • 本地判定:isLicensed() / 自研等价函数返回 true。
  • 搬运失效:把 license.dat 换到别的域名解密 → 失败(AAD 不匹配)。
  • 篡改失效:改动密文任意一字节 → 解密失败。
  • 心跳正常:heartbeat 返回 code:0 且含 server_time。
  • 吊销生效:服务端把码禁用后,心跳返回 2002,本地凭据被删除。
  • 密钥不外泄:站点前台源码 / 浏览器网络请求里搜不到 app_key。
  • 凭据不落库:站点数据库中查不到授权码明文。
✅
十条全过,接入即完成。任何一条不过,回到对应章节排查 —— 多数问题集中在 域名归一化(换域名/带 www)与 签名 canonical(字段多寡/排序)两处。

本文档适用于 AuthLink v1.5.5 · PHP SDK 1.4.0 · 接口契约以服务端《API.md》为准