|
|
@@ -0,0 +1,216 @@
|
|
|
+# Pinnacle Proxy API Auth
|
|
|
+
|
|
|
+`/api/pinnacle` 不需要登录态,但每个调用方需要配置独立用户和密钥,并通过动态签名访问接口。
|
|
|
+
|
|
|
+## 服务端配置
|
|
|
+
|
|
|
+在 `pinnacle/.env` 中配置:
|
|
|
+
|
|
|
+```env
|
|
|
+PINNACLE_PROXY_USERS=alice:aliceSecret:/events,/v3/sports;bob:bobSecret:*
|
|
|
+```
|
|
|
+
|
|
|
+格式:
|
|
|
+
|
|
|
+```txt
|
|
|
+用户名:密钥:权限列表;用户名:密钥:权限列表
|
|
|
+```
|
|
|
+
|
|
|
+说明:
|
|
|
+
|
|
|
+- `用户名`:前端请求时放在 `X-Api-User`。
|
|
|
+- `密钥`:只保存在服务端和可信调用方,不在请求中明文传递。
|
|
|
+- `权限列表`:逗号分隔,写 `/api/pinnacle` 后面的实际路径。
|
|
|
+- `*` 表示允许访问所有 `/api/pinnacle` 接口。
|
|
|
+
|
|
|
+示例:
|
|
|
+
|
|
|
+```env
|
|
|
+PINNACLE_PROXY_USERS=viewer:viewerSecret:/events,/v2/currencies;trader:traderSecret:/events,/v4/bets/place,/v4/bets/parlay
|
|
|
+```
|
|
|
+
|
|
|
+对应权限:
|
|
|
+
|
|
|
+- `viewer` 可以访问 `/api/pinnacle/events` 和 `/api/pinnacle/v2/currencies`
|
|
|
+- `trader` 可以访问 `/api/pinnacle/events`、`/api/pinnacle/v4/bets/place`、`/api/pinnacle/v4/bets/parlay`
|
|
|
+
|
|
|
+## 请求头
|
|
|
+
|
|
|
+前端每次请求 `/api/pinnacle` 都需要带:
|
|
|
+
|
|
|
+```txt
|
|
|
+X-Api-User: alice
|
|
|
+X-Api-Timestamp: 1710000000000
|
|
|
+X-Api-Nonce: random-string
|
|
|
+X-Api-Signature: hmac-sha256-hex
|
|
|
+```
|
|
|
+
|
|
|
+说明:
|
|
|
+
|
|
|
+- `X-Api-Timestamp`:当前毫秒时间戳,允许和服务端相差 5 分钟。
|
|
|
+- `X-Api-Nonce`:每次请求都要不同,防止重放。
|
|
|
+- `X-Api-Signature`:用用户密钥对签名内容做 HMAC-SHA256。
|
|
|
+
|
|
|
+## 签名内容
|
|
|
+
|
|
|
+签名字符串由 6 行组成:
|
|
|
+
|
|
|
+```txt
|
|
|
+METHOD
|
|
|
+PATH
|
|
|
+QUERY_JSON
|
|
|
+BODY_SHA256
|
|
|
+TIMESTAMP
|
|
|
+NONCE
|
|
|
+```
|
|
|
+
|
|
|
+示例:
|
|
|
+
|
|
|
+```txt
|
|
|
+GET
|
|
|
+/events
|
|
|
+{"id":"123"}
|
|
|
+e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
|
|
+1710000000000
|
|
|
+abc123
|
|
|
+```
|
|
|
+
|
|
|
+其中:
|
|
|
+
|
|
|
+- `METHOD`:大写 HTTP 方法,例如 `GET`、`POST`
|
|
|
+- `PATH`:`/api/pinnacle` 后面的路径,例如 `/events`、`/v4/bets/place`
|
|
|
+- `QUERY_JSON`:query 参数按 key 排序后的 JSON 字符串
|
|
|
+- `BODY_SHA256`:body JSON 按 key 排序后做 SHA256;空 body 使用空字符串的 SHA256
|
|
|
+- `TIMESTAMP`:同 `X-Api-Timestamp`
|
|
|
+- `NONCE`:同 `X-Api-Nonce`
|
|
|
+
|
|
|
+## 前端示例
|
|
|
+
|
|
|
+浏览器端可使用 Web Crypto API:
|
|
|
+
|
|
|
+```js
|
|
|
+const stableStringify = (value) => {
|
|
|
+ if (Array.isArray(value)) {
|
|
|
+ return `[${value.map(stableStringify).join(',')}]`;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (value && typeof value === 'object') {
|
|
|
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ return JSON.stringify(value);
|
|
|
+};
|
|
|
+
|
|
|
+const toHex = (buffer) => {
|
|
|
+ return [...new Uint8Array(buffer)]
|
|
|
+ .map((byte) => byte.toString(16).padStart(2, '0'))
|
|
|
+ .join('');
|
|
|
+};
|
|
|
+
|
|
|
+const sha256 = async (text) => {
|
|
|
+ const data = new TextEncoder().encode(text);
|
|
|
+ const hash = await crypto.subtle.digest('SHA-256', data);
|
|
|
+ return toHex(hash);
|
|
|
+};
|
|
|
+
|
|
|
+const hmacSha256 = async (secret, text) => {
|
|
|
+ const key = await crypto.subtle.importKey(
|
|
|
+ 'raw',
|
|
|
+ new TextEncoder().encode(secret),
|
|
|
+ { name: 'HMAC', hash: 'SHA-256' },
|
|
|
+ false,
|
|
|
+ ['sign'],
|
|
|
+ );
|
|
|
+ const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(text));
|
|
|
+ return toHex(signature);
|
|
|
+};
|
|
|
+
|
|
|
+const createPinnacleHeaders = async ({ method, path, query = {}, body, user, secret }) => {
|
|
|
+ const timestamp = String(Date.now());
|
|
|
+ const nonce = crypto.randomUUID();
|
|
|
+ const bodyText = body === undefined || body === null ? '' : stableStringify(body);
|
|
|
+ const bodyHash = await sha256(bodyText);
|
|
|
+
|
|
|
+ const payload = [
|
|
|
+ method.toUpperCase(),
|
|
|
+ path,
|
|
|
+ stableStringify(query),
|
|
|
+ bodyHash,
|
|
|
+ timestamp,
|
|
|
+ nonce,
|
|
|
+ ].join('\n');
|
|
|
+
|
|
|
+ const signature = await hmacSha256(secret, payload);
|
|
|
+
|
|
|
+ return {
|
|
|
+ 'X-Api-User': user,
|
|
|
+ 'X-Api-Timestamp': timestamp,
|
|
|
+ 'X-Api-Nonce': nonce,
|
|
|
+ 'X-Api-Signature': signature,
|
|
|
+ };
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+GET 请求:
|
|
|
+
|
|
|
+```js
|
|
|
+const user = 'alice';
|
|
|
+const secret = 'aliceSecret';
|
|
|
+const path = '/events';
|
|
|
+const query = { ids: '123,456' };
|
|
|
+const search = new URLSearchParams(query).toString();
|
|
|
+
|
|
|
+const headers = await createPinnacleHeaders({
|
|
|
+ method: 'GET',
|
|
|
+ path,
|
|
|
+ query,
|
|
|
+ user,
|
|
|
+ secret,
|
|
|
+});
|
|
|
+
|
|
|
+const res = await fetch(`/api/pinnacle${path}?${search}`, { headers });
|
|
|
+const data = await res.json();
|
|
|
+```
|
|
|
+
|
|
|
+POST 请求:
|
|
|
+
|
|
|
+```js
|
|
|
+const user = 'trader';
|
|
|
+const secret = 'traderSecret';
|
|
|
+const path = '/v4/bets/place';
|
|
|
+const body = {
|
|
|
+ uniqueRequestId: crypto.randomUUID(),
|
|
|
+ stake: 100,
|
|
|
+ winRiskStake: 'RISK',
|
|
|
+ oddsFormat: 'DECIMAL',
|
|
|
+ lineId: 123456,
|
|
|
+};
|
|
|
+
|
|
|
+const headers = await createPinnacleHeaders({
|
|
|
+ method: 'POST',
|
|
|
+ path,
|
|
|
+ body,
|
|
|
+ user,
|
|
|
+ secret,
|
|
|
+});
|
|
|
+
|
|
|
+const res = await fetch(`/api/pinnacle${path}`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: {
|
|
|
+ ...headers,
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ },
|
|
|
+ body: JSON.stringify(body),
|
|
|
+});
|
|
|
+
|
|
|
+const data = await res.json();
|
|
|
+```
|
|
|
+
|
|
|
+## 常见错误
|
|
|
+
|
|
|
+- `401 Missing API signature headers`:缺少签名请求头。
|
|
|
+- `401 Invalid API timestamp`:时间戳过期或不是毫秒时间戳。
|
|
|
+- `401 API nonce has already been used`:nonce 重复。
|
|
|
+- `401 Invalid API signature`:签名不匹配。
|
|
|
+- `403 Forbidden`:用户没有该接口权限。
|
|
|
+
|