ZYZHIXI OAuth2 关联授权文档
本文档定义了 ZYZHIXI OAuth2 代理服务的接口规范与安全建议。
1. 基础信息
| 项目 | 说明 |
| Base URL | https://www.zyzhixi.com/wp-json/zibll-oauth/v1 |
| 认证模式 | 标准 OAuth2 Authorization Code Grant |
| 安全传输 | 必须使用 HTTPS |
2. 术语与参数
| 字段 | 说明 |
| client_id | 应用唯一标识(在 Provider 后台创建应用获取) |
| client_secret | 应用密钥(请勿泄露,仅限服务端使用) |
| redirect_uri | 授权回调地址(必须与后台配置完全一致,精确匹配) |
| code | 临时授权码(有效期 10 分钟,一次性) |
| access_token | 资源访问令牌(默认有效期 2 小时) |
| refresh_token | 刷新令牌(有效期 180 天,滑动过期,每次刷新会轮换) |
3. 核心流程
3.1 授权 (Authorize)
引导用户在浏览器访问:GET /authorize
请求参数:
| 参数 | 说明 |
| response_type | 固定为 `code` |
| client_id | 应用标识 |
| redirect_uri | 回调地址 |
| state | 接入方生成的随机串(用于防 CSRF,回调时原样返回) |
| scope | 权限范围(默认 `basic`,可选 `email`, `profile`, `phone`) |
用户同意后回调:{redirect_uri}?code={CODE}&state={STATE}
3.2 换取令牌 (Token)
接入方后端通过 code 换取令牌:POST /token
请求参数(Form 或 JSON):
| 参数 | 说明 |
| grant_type | 固定为 `authorization_code` |
| client_id | 应用标识 |
| client_secret | 应用密钥 |
| code | 回调获取的 code |
| redirect_uri | 必须与授权时一致 |
成功响应 (200 OK):
json
{
“access_token”: “AT_xxx”,
“token_type”: “bearer”,
“expires_in”: 7200,
“refresh_token”: “RT_xxx”,
“refresh_token_expires_in”: 15552000
}
错误响应:
json
{
“code”: “invalid_grant”,
“message”: “code 无效或已过期”,
“data”: {}
}
3.3 刷新令牌 (Refresh)
当 `access_token` 快过期时,使用 `refresh_token` 获取新令牌:POST /token
请求参数:
| 参数 | 说明 |
| grant_type | 固定为 `refresh_token` |
| client_id | 应用标识 |
| client_secret | 应用密钥 |
| refresh_token | 当前的 refresh_token |
> 说明: `refresh_token` 采用旋转机制,每次刷新都会返回新的 refresh_token,旧令牌立即失效。
3.4 吊销令牌 (Revoke)
主动失效令牌:POST /revoke
请求参数:
| 参数 | 说明 |
| client_id | 应用标识 |
| client_secret | 应用密钥 |
| token | 要吊销的令牌 |
| token_type_hint | 可选 `access_token` 或 `refresh_token` |
成功响应 (200 OK):
json
{
“revoked”: true
}
错误响应:
json
{
“code”: “invalid_client”,
“message”: “client_secret 无效”,
“data”: {}
}
4. 用户信息接口
4.1 获取用户信息 (UserInfo)
GET /userinfo
Header: Authorization: Bearer <access_token>
成功响应 (200 OK):
json
{
“userinfo”: {
“openid”: “o_xxx”,
“name”: “用户名”,
“avatar”: “https://…”,
“email”: “user@example.com”,
“phone”: “+8613800000000”,
“description”: “个人简介”
}
}
4.2 获取 UnionID (UnionID)
GET /unionid
Header: Authorization: Bearer <access_token>
成功响应 (200 OK):
json
{
“openid”: “o_xxx”,
“unionid”: “123”
}
5. 服务端回调与签名安全
当异步任务(如撤销授权)完成时,Provider 会 `POST application/json` 到接入方。
5.1 撤销授权回调
当用户撤销授权时,Provider 会向应用配置的回调地址发送通知。
请求方法: `POST`
Content-Type: `application/json`
请求体示例:
json
{
“appid”: “app_xxx”,
“openid”: “o_xxx”,
“user_id”: 123,
“status”: “revoked”,
“revoked_at”: 1704067200,
“timestamp”: 1704067200,
“nonce”: “random_str”,
“event_id”: “evt_xxx”,
“sign2”: “sha256_signature”
}
字段说明:
| 字段 | 说明 |
| appid | 应用标识 |
| openid | 用户 OpenID |
| user_id | 用户 ID |
| status | 状态(revoked) |
| revoked_at | 撤销时间(Unix 时间戳) |
| timestamp | Unix 时间戳 |
| nonce | 随机字符串 |
| event_id | 事件 ID(用于去重) |
| sign2 | 签名 |
接入方应返回:
json
{
“code”: 0,
“message”: “success”
}
5.2 签名算法 (Sign2)
全链路仅支持 V2 全字段签名。算法如下:
1. 排除 `sign` 和 `sign2` 参数
2. 将剩余所有参数按 Key 的 ASCII 码升序排序
3. 将排序后的参数拼成 `key1=value1&key2=value2`(value 为数组或对象时转 JSON,字符进行 rawurlencode)
4. 使用 `client_secret` 作为 Key,对该字符串执行 HMAC-SHA256
5.3 幂等建议
回调 Payload 包含 `event_id`。接入方应:
验签:验证 `sign2`
去重:检查 `event_id` 是否已处理过
处理:执行业务逻辑并返回 200 OK
6. 错误码规范
| 错误码 | HTTP | 说明 |
| invalid_request | 400 | 参数缺失或格式错误 |
| invalid_client | 401 | client_secret 校验失败 |
| invalid_grant | 400 | code/refresh_token 无效或已过期 |
| unauthorized_client | 403 | 应用不存在或未启用 |
| ip_not_allowed | 403 | 调用 IP 不在白名单 |
### 7.3 轮换 AppKey
“`
POST /wp-json/zibll-oauth/v1/appkey/rotate
Content-Type: application/json
“`
**请求体:**
“`json
{
“identity”: “user@example.com”,
“password_hash”: “pbkdf2_sha256_64位hex字符串”,
“nonce”: “上一步获取的nonce”,
“app_id”: “zo_xxxxxxxxxxxxxx”,
“old_app_key_hash”: “sha256_64位hex字符串”
}
“`
| 参数 | 必填 | 说明 |
|——|——|——|
| `identity` | 是 | 用户邮箱/用户名/手机号 |
| `password_hash` | 是 | `PBKDF2-SHA256(用户密码, nonce, 100000次迭代)` 的结果(64位 hex) |
| `nonce` | 是 | 上一步获取的 nonce(一次性) |
| `app_id` | 应用的 AppID | 是 |
| `old_app_key_hash` | 是 | `SHA256(当前AppKey明文)` 的结果(64位 hex) |
**成功响应 (200 OK):**
“`json
{
“code”: 0,
“message”: “appkey rotated successfully”,
“data”: {
“app_id”: “zo_xxxxxxxxxxxxxx”,
“new_app_key_plain”: “新生成的32位随机密钥”,
“expires_at”: “2026-05-13T10:00:00+00:00”,
“old_app_key_valid_until”: “2026-05-12T10:05:00+00:00”
}
}
“`
> **重要:** `new_app_key_plain` 仅在本次响应中返回一次,客户端必须立即安全保存。服务端仅存储其 bcrypt 哈希值。
**缓冲期说明:**
– 新旧密钥同时有效 **5 分钟**(`old_app_key_valid_until`),避免切换瞬间业务中断
– 缓冲期结束后旧密钥彻底失效
**错误响应:**
| 错误码 | HTTP | 说明 |
|——–|——|——|
| `40004` | 400 | Invalid or expired nonce(nonce 无效或过期) |
| `40101` | 401 | Invalid username or password(密码验证失败) |
| `40301` | 403 | 应用不存在 |
| `40302` | 403 | app_id 和 old_app_key 不匹配或不属于该用户 |
| `40303` | 403 | 该应用未启用 API 轮换功能 |
| `42901` | 429 | 轮换频率超限(每应用每24小时1次) |
### 7.4 客户端实现示例(JavaScript)
“`javascript
const BASE_URL = ‘/wp-json/zibll-oauth/v1’;
async function sha256(message) {
const encoder = new TextEncoder();
const data = encoder.encode(message);
const hash = await crypto.subtle.digest(‘SHA-256’, data);
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, ‘0’)).join(”);
}
async function pbkdf2Hash(password, salt, iterations = 100000) {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(‘raw’, encoder.encode(password), ‘PBKDF2’, false, [‘deriveBits’]);
const derivedBits = await crypto.subtle.deriveBits(
{ name: ‘PBKDF2’, salt: encoder.encode(salt), iterations, hash: ‘SHA-256’ },
keyMaterial, 256
);
return Array.from(new Uint8Array(derivedBits)).map(b => b.toString(16).padStart(2, ‘0’)).join(”);
}
async function rotateAppKey(identity, plainPassword, appId, oldAppKeyPlain) {
// 1. 获取 nonce
const nonceRes = await fetch(`${BASE_URL}/get-nonce?identity=${encodeURIComponent(identity)}`);
const { data: { nonce } } = await nonceRes.json();
// 2. 计算哈希
const passwordHash = await pbkdf2Hash(plainPassword, nonce);
const oldKeyHash = await sha256(oldAppKeyPlain);
// 3. 发起轮换
const res = await fetch(`${BASE_URL}/appkey/rotate`, {
method: ‘POST’,
headers: { ‘Content-Type’: ‘application/json’ },
body: JSON.stringify({
identity,
password_hash: passwordHash,
nonce,
app_id: appId,
old_app_key_hash: oldKeyHash
})
});
const result = await res.json();
if (result.code === 0) {
console.log(‘轮换成功,新密钥:’, result.data.new_app_key_plain);
console.log(‘旧密钥有效期至:’, result.data.old_app_key_valid_until);
return result.data;
} else {
throw new Error(result.message || ‘轮换失败’);
}
}
“`
### 7.5 安全机制
| 防护目标 | 采用方案 |
|———|———|
| 传输中密码泄露 | 发送 `PBKDF2(密码+nonce)`,不发送明文密码 |
| 服务端密码存储 | 仅存储 WordPress 原生哈希,绝不存储明文 |
| 旧密钥泄露 | 仅存储 `bcrypt(app_key)` 或 `SHA256(app_key)` 用于比对 |
| 重放攻击 | `nonce` 一次性有效(60秒过期,使用后失效) |
| 轮换后业务中断 | 新旧密钥同时有效 5 分钟(缓冲期) |
| 暴力破解 | PBKDF2 迭代 100000 次 + bcrypt cost ≥ 12 |
| 频率滥用 | 每 24 小时限制 1 次 |
### 7.6 时序图
![图片[1] - ZYZHIXI OAuth2 关联授权文档 - 正仪芷汐](https://www.zyzhixi.com/wp-content/uploads/2026/05/20260512234856415-image-1024x546.webp)
### 7.7 密码诊断接口(/pwd-debug)
用于调试密码验证失败问题,返回服务端存储的密码摘要状态和最近一次失败的详细比对信息。
> **重要:** 该端点默认关闭,需在管理后台「AppKey 轮换管理」→「轮换设置」→「密码诊断端点」中为每个应用单独开启。
“`
GET /wp-json/zibll-oauth/v1/pwd-debug?identity={用户邮箱/用户名}
“`
**参数:**
| 参数 | 必填 | 说明 |
|——|——|——|
| `identity` | 是 | 用户邮箱、用户名或手机号 |
**成功响应 (200 OK):**
“`json
{
“code”: 0,
“data”: {
“user_id”: 3,
“user_login”: “testuser”,
“user_email”: “user@test.com”,
“has_pwd_sha256”: true,
“pwd_sha256_prefix”: “b4229cb8a482c72c”,
“pwd_sha256_len”: 64,
“last_debug_info”: {
“issue”: “hash_mismatch”,
“stored_pwd_sha256_prefix”: “b4229cb8a482c72c”,
“expected_pbkdf2_prefix”: “6a734ed95fbcc33e4d74d830”,
“expected_pbkdf2_suffix”: “b9847873629b9cc3de31e3df”,
“expected_pbkdf2_len”: 64,
“received_hash_prefix”: “6a734ed95fbcc33e4d74d830”,
“received_hash_suffix”: “86ec16e38fb1f0512e0015ff”,
“received_hash_len”: 128,
“received_clean_len”: 128,
“received_trimmed_len”: 128,
“exact_match”: “NO”,
“clean_matches”: “NO”,
“trimmed_matches”: “NO”
}
}
}
“`
**字段说明:**
| 字段 | 说明 |
|——|——|
| `has_pwd_sha256` | 服务端是否已存储该用户的密码 SHA256 摘要 |
| `pwd_sha256_prefix` | 存储的 SHA256(password) 前16位(仅前缀,不泄露完整哈希) |
| `last_debug_info` | 最近一次密码验证失败时的详细比对数据,无失败记录时为 `null` |
| `issue` | 失败原因类型:`hash_mismatch`(哈希不匹配)或 `no_sha256_stored`(未存储摘要) |
| `expected_pbkdf2_*` | 服务端使用存储的摘要+nonce 计算出的期望值 |
| `received_hash_*` | 客户端发送的值 |
| `clean_matches` / `trimmed_matches` | 去除控制字符或 trim 后是否匹配 |
**错误响应:**
| 错误码 | HTTP | 说明 |
|——–|——|——|
| `debug_disabled` | 403 | 该应用未开启密码诊断功能 |
### 7.8 PBKDF2 输出长度规范
客户端与服务端的 PBKDF2 输出必须严格一致:
| 参数 | 客户端 (Web Crypto) | 服务端 (PHP) |
|——|——————-|————–|
| 算法 | `PBKDF2` + `SHA-256` | `hash_pbkdf2(‘sha256’, …)` |
| 迭代次数 | 100000 | 100000 |
| **输出位数** | **256 bits** | **64 bytes → hex = 64 字符** |
| **Hex 长度** | **64 字符** | **64 字符** |
> **关键:** 客户端 `deriveBits(…, 256)` 对应 PHP 的第4参数 `64`(64字节=512 bits 的原始输出被编码为 128 hex 字符是错误的)。正确对应关系:`deriveBits(256)` → 32 bytes → **64 hex 字符**。
### 7.9 后台管理功能
#### 7.9.1 AppKey 轮换管理弹窗
在「OAuth 应用审核管理」→ 操作列 → 「轮换」按钮打开,包含三个 Tab:
**轮换设置 Tab:**
| 设置项 | 默认值 | 说明 |
|——–|——–|——|
| 启用 API 轮换 | 关闭 | 开启后该应用可通过 API 接口进行 AppKey 轮换 |
| API 轮换需密码验证 | 开启 | 关闭后 API 轮换仅验证身份和旧密钥 |
| 密码诊断端点 | **关闭** | 开启后关联用户可通过 `/pwd-debug` 查询密码摘要状态 |
| 时间限制管理 | – | 一键解除频率限制和缓冲期 |
| 密码摘要同步 | – | 手动输入明文密码验证并同步 SHA256 摘要 |
**手动轮换 Tab:**
– 管理员直接执行轮换,立即生成新密钥
– 新密钥仅显示一次,需及时复制保存
**API 轮换日志 Tab:**
– 展示该应用的所有轮换操作记录(API 轮换 / 手动轮换)
– 支持按时间、类型、结果、IP 筛选
– 日志管理功能:
– **全部清空**:删除该应用的所有轮换日志
– **按日期清理**:删除指定日期之前的日志
– 显示日志总数和最早记录时间
#### 7.9.2 测试插件配置管理
独立测试插件 `zibll-oauth-rotate-test.php` 支持:
– 配置目标服务器地址(支持跨站)
– 自动检测 `/wp-json/` 可用性,不可用时自动降级到 `?rest_route=` 模式
– 保存/加载/删除测试用户配置(身份、密码、AppID、旧密钥)
– 密码诊断面板:调用 `/pwd-debug` 端点展示详细比对信息
– 完整的三步式测试流程:获取 Nonce → 计算哈希 → 执行轮换
部分开发者可能会使用 AI 工具辅助开发,这边直接提供MD文档。

请登录后查看评论内容