1. 接入概述
device_limit)由服务端接入服务配置决定,客户端传入的 device_limit 值将被忽略。v1.4.0 起,POST /api/validate 只接受”接入服务密钥”协议。旧版全局 API_SECRET 验签方式已废弃,不存在回退、兼容或双轨验签逻辑。
signed_license。旧客户端可忽略该新增字段,新客户端可用系统公钥本地验签,防止本地授权状态、过期时间和设备绑定信息被篡改。
生产环境必须通过 HTTPS 暴露接口。签名密钥 secret_key 不应写入可逆向客户端,推荐由客户自己的服务端代理调用验证接口。
当前 Docker 镜像默认以单 worker 运行,因为验证限流器是进程内实现。多 worker、多容器或横向扩容部署前,需要先把限流迁移到 Redis 等集中式实现。
2. 接入前准备
管理员在后台“接入服务密钥”页面创建接入服务后,会得到两类凭证。
| 凭证 | 说明 |
|---|---|
access_key | 公开标识,放入请求头 X-Access-Key。 |
secret_key | 签名密钥,只在创建或轮换时展示一次。 |
secret_key 泄露,应立即在后台禁用对应接入服务;确认接入方完成更新后再轮换密钥。轮换后旧 secret_key 立即失效。3. 请求格式
请求体必须是 JSON object。签名只覆盖 JSON 请求体,不包含 Header。
POST /api/validate
Content-Type: application/json
X-Access-Key: ak_xxxxxxxxxxxxxxxxx
X-Api-Signature: <hmac-sha256>
{
"key": "LICENSE_KEY",
"machine_id": "DEVICE-001",
"device_limit": 5,
"timestamp": 1779123456,
"nonce": "b5f2b0b7b2564bb9a48b7f6a3d3d83a1"
}
| 字段 | 位置 | 必填 | 规则 |
|---|---|---|---|
X-Access-Key | Header | 是 | 接入服务公开标识,缺失返回 MISSING_ACCESS_KEY。 |
X-Api-Signature | Header | 是 | 使用接入服务 secret_key 计算的 HMAC-SHA256。 |
key | Body | 是 | 许可证密钥。 |
machine_id | Body | 是 | 设备指纹。服务端只在绑定表中保存 SHA256 摘要。 |
device_limit | Body | 否 | v1.5.0 起由服务端接入服务配置决定,客户端传入值将被忽略。保留此字段仅为签名兼容。 |
timestamp | Body | 是 | Unix 秒级时间戳,默认允许与服务器时间偏差 300 秒。 |
nonce | Body | 是 | 每次请求唯一随机字符串。 |
4. 签名算法
服务端签名算法与 app/core/security.py 保持一致。
import hashlib
import hmac
import json
def generate_signature(secret_key: str, body: dict) -> str:
payload = json.dumps(body, sort_keys=True, separators=(",", ":"))
return hmac.new(
secret_key.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
sort_keys=True,必须按字段名排序。separators=(",", ":"),JSON 中不保留空格。timestamp、nonce 和签名。5. 调用示例
Python
import hashlib
import hmac
import json
import secrets
import time
import requests
ACCESS_KEY = "ak_xxxxxxxxxxxxxxxxx"
SECRET_KEY = "创建或轮换时展示的一次性secret_key"
API_ENDPOINT = "https://your-domain.com/api/validate"
def generate_signature(secret_key: str, body: dict) -> str:
payload = json.dumps(body, sort_keys=True, separators=(",", ":"))
return hmac.new(
secret_key.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
body = {
"key": "LICENSE_KEY",
"machine_id": "DEVICE-001",
"device_limit": 5,
"timestamp": int(time.time()),
"nonce": secrets.token_hex(16),
}
response = requests.post(
API_ENDPOINT,
json=body,
headers={
"X-Access-Key": ACCESS_KEY,
"X-Api-Signature": generate_signature(SECRET_KEY, body),
},
timeout=10,
)
print(response.status_code)
print(response.json())
Node.js
const crypto = require('crypto');
const ACCESS_KEY = 'ak_xxxxxxxxxxxxxxxxx';
const SECRET_KEY = '创建或轮换时展示的一次性secret_key';
const API_ENDPOINT = 'https://your-domain.com/api/validate';
function stableStringify(value) {
const keys = Object.keys(value).sort();
const ordered = {};
for (const key of keys) ordered[key] = value[key];
return JSON.stringify(ordered);
}
function generateSignature(secretKey, body) {
return crypto
.createHmac('sha256', Buffer.from(secretKey, 'utf8'))
.update(Buffer.from(stableStringify(body), 'utf8'))
.digest('hex');
}
async function validateLicense() {
const body = {
key: 'LICENSE_KEY',
machine_id: 'DEVICE-001',
device_limit: 5,
timestamp: Math.floor(Date.now() / 1000),
nonce: crypto.randomBytes(16).toString('hex'),
};
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Access-Key': ACCESS_KEY,
'X-Api-Signature': generateSignature(SECRET_KEY, body),
},
body: JSON.stringify(body),
});
console.log(response.status, await response.json());
}
validateLicense().catch(console.error);
6. 成功响应
首次验证未激活许可证时,服务端会激活许可证并返回激活信息。
{
"valid": true,
"status": "active",
"duration_type": "1个月",
"activated_at": "2026-05-20T18:30:00+08:00",
"expire_at": "2026-06-19T18:30:00+08:00",
"message": "许可证已成功激活",
"device_binding": {
"action": "bound_new",
"message": "设备已完成绑定",
"machine_id_hash": "b7d8f0f2a4c9e1d6...",
"bound_device_count": 1,
"device_limit": 3
},
"signed_license": {
"alg": "Ed25519",
"kid": "sm-license-ed25519-signing-key",
"payload": "base64url-json-payload",
"signature": "base64url-signature"
}
}
已激活许可证验证成功时,服务端返回剩余天数。
{
"valid": true,
"status": "active",
"duration_type": "1个月",
"activated_at": "2026-05-20T18:30:00+08:00",
"expire_at": "2026-06-19T18:30:00+08:00",
"remaining_days": 29,
"device_binding": {
"action": "verified_existing",
"message": "设备已绑定,本次为正常复核",
"machine_id_hash": "b7d8f0f2a4c9e1d6...",
"bound_device_count": 1,
"device_limit": 3
},
"signed_license": {
"alg": "Ed25519",
"kid": "sm-license-ed25519-signing-key",
"payload": "base64url-json-payload",
"signature": "base64url-signature"
}
}
expire_at 为 null,remaining_days 为 null。device_binding 字段用于告诉接入端本次联网验证后的设备绑定状态。
| 字段 | 说明 |
|---|---|
action | bound_new 表示当前设备本次新绑定成功;verified_existing 表示当前设备已绑定,本次为正常复核。 |
message | 设备绑定状态说明,可用于日志或提示。 |
machine_id_hash | 服务端保存的设备摘要,不返回原始 machine_id。 |
bound_device_count | 当前许可证已绑定设备数量。 |
device_limit | 当前许可证固化后的最大可绑定设备数。 |
signed_license 只在系统级签名许可证配置为“已启用”时返回。
| 字段 | 说明 |
|---|---|
alg | 固定为 Ed25519。 |
kid | 系统级公钥 ID,默认 sm-license-ed25519-signing-key。 |
payload | base64url 编码后的许可证 JSON。 |
signature | 对 payload 字符串本身签名后的 base64url 值。 |
grace_until 和 next_verify_at。终身许可证的 expire_at 和 grace_until 均为 null。7. 时间戳与时区处理
timestamp 是请求防重放字段,使用 Unix 秒级时间戳,不需要转换为北京时间。响应中的 activated_at、expire_at 是许可证业务时间,系统已改为返回带显式时区偏移 +08:00 的 ISO 8601 字符串(例如 2026-05-20T18:30:00+08:00),无需额外假设时区。
+08:00 时区偏移。接入端可直接使用标准 ISO 8601 解析器处理,无需手动追加时区后缀。| 字段 | 格式 | 处理要求 |
|---|---|---|
timestamp | Unix 秒 | 用于签名和 300 秒时间窗口校验,直接使用当前系统 epoch 秒。 |
activated_at | ISO 8601 字符串(含 +08:00 偏移) | 直接解析即可,时间已携带时区信息。 |
expire_at | ISO 8601 字符串(含 +08:00 偏移)或 null | 非空时直接解析判断过期;null 表示终身许可证。 |
// v1.4.0 起响应时间已携带 +08:00 时区偏移,可直接解析
function parseLicenseTime(value) {
if (!value) return null;
return new Date(value);
}
function formatShanghaiTime(value) {
const date = parseLicenseTime(value);
if (!date) return '永久有效';
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(date);
}
// v1.4.0 起响应时间已携带 +08:00 时区偏移,可直接解析
DateTime? parseLicenseTime(String? value) {
if (value == null || value.isEmpty) return null;
return DateTime.parse(value);
}
8. 错误响应
验证接口使用 FastAPI HTTPException 返回错误。实际 JSON 响应外层包含 detail。
{
"detail": {
"valid": false,
"code": "DEVICE_LIMIT_EXCEEDED",
"message": "设备绑定数量已达上限"
}
}
客户端应优先读取 response.detail.code;如遇到通用错误,也可能返回字符串型 detail。
| 错误码 | HTTP 状态码 | 触发条件 |
|---|---|---|
INVALID_REQUEST | 400 | 请求体不是合法 JSON。 |
MISSING_ACCESS_KEY | 401 | 缺少 X-Access-Key。 |
ACCESS_KEY_INVALID | 401 | 接入密钥不存在或已软删除。 |
ACCESS_KEY_DISABLED | 403 | 接入服务已禁用。 |
INVALID_SIGNATURE | 401 | 缺少签名或签名错误。 |
MISSING_KEY | 400 | 缺少许可证密钥 key。 |
MISSING_MACHINE_ID | 400 | 缺少 machine_id。 |
INVALID_DEVICE_LIMIT | 400 | device_limit 不在 1-10 范围内,或大于已固化上限。 |
TIMESTAMP_EXPIRED | 401 | timestamp 缺失、格式无效或超出时间窗口。 |
MISSING_NONCE | 401 | 缺少 nonce。 |
NONCE_REPLAYED | 401 | 同一接入服务下 nonce 重复使用。 |
RATE_LIMITED | 429 | 触发 IP、接入服务、许可证或全局限流。 |
KEY_INVALID | 404 | 许可证密钥不存在。 |
LICENSE_REVOKED | 403 | 许可证已撤销。 |
LICENSE_EXPIRED | 410 | 许可证已过期。 |
DEVICE_LIMIT_EXCEEDED | 403 | 绑定设备数量已达上限。 |
INTERNAL_ERROR | 500 | 服务端内部错误。 |
9. 设备绑定规则
设备绑定由 license_devices 表记录,服务端对 machine_id 计算 SHA256 后保存摘要。后台详情页展示的是设备摘要,不再展示原始设备指纹。
- 所有验证请求必须传入
machine_id。 device_limit由服务端接入服务配置决定(1-10,默认10),管理员在创建或编辑接入服务时设置。客户端传入的device_limit值将被忽略,服务端强制使用接入服务配置的值。- 许可证第一次激活时,服务端会把接入服务配置的
device_limit固化到许可证。 - 后续验证中,服务端使用接入服务配置的
device_limit与已固化值校验。 - 同一
machine_id重复验证不会增加绑定数量。 - 新
machine_id在未达到固化上限时自动绑定。 - 达到上限后,新设备验证返回
DEVICE_LIMIT_EXCEEDED。 - 管理员在后台删除某个绑定设备后,该设备下一次联网验证会按新设备重新校验:未超限则返回
device_binding.action=bound_new并重新绑定;已超限则返回DEVICE_LIMIT_EXCEEDED。
device_limit=3,则通过该接入服务激活的许可证最多绑定 3 个设备。管理员后续修改接入服务的 device_limit 不影响已固化的许可证。device_limit 配置(默认10),不再使用客户端传入的值。DEVICE_LIMIT_EXCEEDED、LICENSE_REVOKED、LICENSE_EXPIRED 或 KEY_INVALID 时,应立即停用本地授权;收到 device_binding.action=bound_new 时,应更新本地授权绑定状态。10. 防重放规则
服务端先校验签名和时间戳,再检查并写入 nonce。nonce 按接入服务维度唯一。
nonce。11. 限流规则
验证接口包含 4 个维度的限流,默认值如下。
| 维度 | 默认值 | 环境变量 |
|---|---|---|
| IP | 60 次/分钟 | VALIDATION_IP_PER_MINUTE=60 |
| 接入服务 | 300 次/分钟 | VALIDATION_ACCESS_KEY_PER_MINUTE=300 |
| 许可证摘要 | 30 次/分钟 | VALIDATION_LICENSE_PER_MINUTE=30 |
| 全局 | 3000 次/分钟 | VALIDATION_GLOBAL_PER_MINUTE=3000 |
12. 审计日志与隐私
每次验证成功或失败都会写入接入服务日志。日志记录接入服务 ID、接入密钥快照、载体名称快照、许可证密钥 SHA256 前 16 位摘要、machine_id SHA256 前 16 位摘要、IP、User-Agent、验证结果、错误码、HTTP 状态码和响应耗时。
13. 客户端处理与常见排查
| HTTP 状态码 | 客户端处理建议 |
|---|---|
| 401 | 检查接入服务密钥、签名、时间戳、nonce。 |
| 403 | 检查接入服务是否禁用、许可证是否撤销、设备数是否超限。 |
| 404 | 许可证不存在。 |
| 410 | 许可证已过期。 |
| 429 | 请求过于频繁,按 retry_after 或固定退避重试。 |
secret_key,不是旧全局 API_SECRET;确认参与签名的 JSON 与实际发送 body 完全一致。+08:00 时区偏移,可直接解析。device_limit 是否符合预期。v1.5.0 起,设备绑定数量由接入服务的服务端配置决定,不再由客户端控制。