ZYZHIXI OAuth2 关联授权文档

本文档定义了 ZYZHIXI OAuth2 代理服务的接口规范与安全建议。

1. 基础信息

项目说明
Base URLhttps://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 时间戳)
timestampUnix 时间戳
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_request400参数缺失或格式错误
invalid_client401client_secret 校验失败
invalid_grant400code/refresh_token 无效或已过期
unauthorized_client403应用不存在或未启用
ip_not_allowed403调用 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 关联授权文档 - 正仪芷汐

### 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文档。

zyzhixi-ouath.md
提取码
ZYZX
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容

...
Zzz...